This commit is contained in:
Mateusz Gruszczyński
2026-09-20 13:56:53 +02:00
parent a6efa52f78
commit 0a269cf6fb
11 changed files with 437 additions and 22 deletions
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]] [[package]]
name = "rustpad" name = "rustpad"
version = "0.2.77" version = "0.2.78"
dependencies = [ dependencies = [
"argon2", "argon2",
"aws-config", "aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.2.77" version = "0.2.78"
edition = "2024" edition = "2024"
rust-version = "1.94" rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+1 -1
View File
@@ -148,7 +148,7 @@ Optional databases in Docker Compose:
```bash ```bash
# PostgreSQL # PostgreSQL
docker compose --profile postgres up -d postgres docker compose --profile postgres up -d pgsql
DATABASE_URL=postgres://rustpad:rustpad@postgres:5432/rustpad docker compose up -d rustpad DATABASE_URL=postgres://rustpad:rustpad@postgres:5432/rustpad docker compose up -d rustpad
# MySQL # MySQL
+350
View File
@@ -0,0 +1,350 @@
#!/usr/bin/env bash
set -Eeuo pipefail
usage() {
cat <<'USAGE'
Usage:
bash scripts/load-speed.sh 'https://rustpad.example.com/p/test-note-features'
bash scripts/load-speed.sh 'https://rustpad.example.com/w/team'
bash scripts/load-speed.sh 'https://rustpad.example.com/w/team/n/note'
The script will prompt for the RustPad account login and password.
If the pad/workspace also has resource password protection, it will ask for that password separately.
USAGE
}
for cmd in curl jq wc mktemp; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "ERROR: required command not found: $cmd" >&2
exit 1
fi
done
if [[ $# -ne 1 ]]; then
usage
exit 1
fi
INPUT_URL="$1"
if [[ ! "$INPUT_URL" =~ ^(https?://[^/]+)(/.*)?$ ]]; then
echo "ERROR: pass a full URL starting with http:// or https://" >&2
exit 1
fi
BASE="${BASH_REMATCH[1]}"
PATH_WITH_QUERY="${BASH_REMATCH[2]:-/}"
PATH_ONLY="${PATH_WITH_QUERY%%\#*}"
PATH_ONLY="${PATH_ONLY%%\?*}"
[[ "$PATH_ONLY" != "/" ]] && PATH_ONLY="${PATH_ONLY%/}"
TYPE=""
RESOURCE_KIND=""
RESOURCE_SLUG=""
API_BASE=""
WORKSPACE_SLUG=""
NOTE_SLUG=""
PAD_SLUG=""
if [[ "$PATH_ONLY" =~ ^/p/([^/]+)$ ]]; then
TYPE="pad"
RESOURCE_KIND="pad"
PAD_SLUG="${BASH_REMATCH[1]}"
RESOURCE_SLUG="$PAD_SLUG"
API_BASE="$BASE/api/pads/$PAD_SLUG"
elif [[ "$PATH_ONLY" =~ ^/w/([^/]+)/n/([^/]+)$ ]]; then
TYPE="workspace-note"
RESOURCE_KIND="workspace"
WORKSPACE_SLUG="${BASH_REMATCH[1]}"
NOTE_SLUG="${BASH_REMATCH[2]}"
RESOURCE_SLUG="$WORKSPACE_SLUG"
API_BASE="$BASE/api/workspaces/$WORKSPACE_SLUG/notes/$NOTE_SLUG"
elif [[ "$PATH_ONLY" =~ ^/w/([^/]+)$ ]]; then
TYPE="workspace"
RESOURCE_KIND="workspace"
WORKSPACE_SLUG="${BASH_REMATCH[1]}"
RESOURCE_SLUG="$WORKSPACE_SLUG"
API_BASE="$BASE/api/workspaces/$WORKSPACE_SLUG"
else
echo "ERROR: unsupported RustPad URL: $PATH_ONLY" >&2
echo "Supported: /p/<pad>, /w/<workspace>, /w/<workspace>/n/<note>" >&2
exit 1
fi
TMP_DIR="$(mktemp -d)"
COOKIE_JAR="$TMP_DIR/cookies.txt"
CSRF_BODY="$TMP_DIR/csrf.json"
LOGIN_BODY="$TMP_DIR/login.json"
INFO_BODY="$TMP_DIR/info.json"
UNLOCK_BODY="$TMP_DIR/unlock.json"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT INT TERM
touch "$COOKIE_JAR"
chmod 600 "$COOKIE_JAR"
CURL_COMMON=(
--silent
--show-error
--compressed
--connect-timeout 10
--max-time 120
-b "$COOKIE_JAR"
-c "$COOKIE_JAR"
)
human_bytes() {
local bytes="$1"
if (( bytes >= 1048576 )); then
awk -v b="$bytes" 'BEGIN { printf "%.2f MiB", b / 1048576 }'
elif (( bytes >= 1024 )); then
awk -v b="$bytes" 'BEGIN { printf "%.1f KiB", b / 1024 }'
else
printf '%s B' "$bytes"
fi
}
measure_to_file() {
local name="$1"
local outfile="$2"
shift 2
local stats code transfer ttfb total body_bytes
stats="$(curl "${CURL_COMMON[@]}" "$@" \
-o "$outfile" \
-w '%{http_code}|%{size_download}|%{time_starttransfer}|%{time_total}')"
IFS='|' read -r code transfer ttfb total <<< "$stats"
body_bytes="$(wc -c < "$outfile" | tr -d '[:space:]')"
printf '%-20s HTTP=%-3s transfer=%10s body=%10s TTFB=%8ss total=%8ss\n' \
"$name" "$code" "$(human_bytes "${transfer%.*}")" "$(human_bytes "$body_bytes")" "$ttfb" "$total"
LAST_HTTP="$code"
}
measure() {
local name="$1"
shift
local outfile="$TMP_DIR/measure-${name//[^a-zA-Z0-9_-]/_}.body"
measure_to_file "$name" "$outfile" "$@"
}
api_post_json() {
local url="$1"
local json="$2"
local outfile="$3"
local code
code="$(curl "${CURL_COMMON[@]}" \
-H 'Content-Type: application/json' \
-H "x-rustpad-csrf: $CSRF" \
--data "$json" \
-o "$outfile" \
-w '%{http_code}' \
"$url")"
LAST_HTTP="$code"
}
fetch_csrf() {
local code
code="$(curl "${CURL_COMMON[@]}" \
-o "$CSRF_BODY" \
-w '%{http_code}' \
"$BASE/api/security/csrf")"
if [[ "$code" != "200" ]]; then
echo "ERROR: CSRF endpoint returned HTTP $code" >&2
cat "$CSRF_BODY" >&2 || true
exit 1
fi
CSRF="$(jq -r '.token // empty' "$CSRF_BODY")"
if [[ -z "$CSRF" ]]; then
echo "ERROR: server did not return a CSRF token" >&2
exit 1
fi
}
login_account() {
local login password payload code message nickname
printf 'Login/e-mail: '
IFS= read -r login
if [[ -z "$login" ]]; then
echo "ERROR: login cannot be empty" >&2
exit 1
fi
printf 'Password: '
IFS= read -rs password
printf '\n'
if [[ -z "$password" ]]; then
echo "ERROR: password cannot be empty" >&2
exit 1
fi
payload="$(jq -nc --arg email "$login" --arg password "$password" '{email:$email,password:$password}')"
unset password
code="$(curl "${CURL_COMMON[@]}" \
-H 'Content-Type: application/json' \
-H "x-rustpad-csrf: $CSRF" \
--data "$payload" \
-o "$LOGIN_BODY" \
-w '%{http_code}' \
"$BASE/api/auth/login")"
unset payload
if [[ "$code" != "200" ]]; then
message="$(jq -r '.error // empty' "$LOGIN_BODY" 2>/dev/null || true)"
echo "ERROR: login failed (HTTP $code)${message:+: $message}" >&2
exit 1
fi
nickname="$(jq -r '.nickname // empty' "$LOGIN_BODY")"
echo "Logged in${nickname:+ as $nickname}."
}
unlock_resource_if_needed() {
local info_file="$1"
local protected access_level resource_password payload code message
protected="$(jq -r '.protected // false' "$info_file" 2>/dev/null || echo false)"
access_level="$(jq -r '.access_level // empty' "$info_file" 2>/dev/null || true)"
if [[ "$protected" != "true" || "$access_level" != "none" ]]; then
return 0
fi
printf 'Resource password (%s): ' "$RESOURCE_KIND"
IFS= read -rs resource_password
printf '\n'
if [[ -z "$resource_password" ]]; then
echo "ERROR: this resource requires a password" >&2
exit 1
fi
payload="$(jq -nc \
--arg kind "$RESOURCE_KIND" \
--arg slug "$RESOURCE_SLUG" \
--arg password "$resource_password" \
'{kind:$kind,slug:$slug,password:$password}')"
unset resource_password
api_post_json "$BASE/api/access-token" "$payload" "$UNLOCK_BODY"
code="$LAST_HTTP"
unset payload
if [[ "$code" != "200" ]]; then
message="$(jq -r '.error // empty' "$UNLOCK_BODY" 2>/dev/null || true)"
echo "ERROR: resource unlock failed (HTTP $code)${message:+: $message}" >&2
exit 1
fi
echo "Resource unlocked."
}
print_header() {
echo
echo "Target: $INPUT_URL"
echo "Type: $TYPE"
echo "Base: $BASE"
echo
}
fetch_csrf
login_account
print_header
echo '=== PAGE / SESSION ==='
measure "page_html" "$BASE$PATH_WITH_QUERY"
measure "auth_me" "$BASE/api/auth/me"
echo
case "$TYPE" in
pad)
echo '=== INITIAL NOTE LOAD ==='
measure_to_file "note_info" "$INFO_BODY" "$API_BASE"
if [[ "$LAST_HTTP" != "200" ]]; then
echo "ERROR: note_info failed; cannot continue reliably." >&2
jq -r '.error // empty' "$INFO_BODY" 2>/dev/null >&2 || true
exit 1
fi
unlock_resource_if_needed "$INFO_BODY"
measure "editor_color" "$API_BASE/editor-color"
measure "favorite_status" "$BASE/api/auth/favorites/status?kind=pad&slug=$PAD_SLUG"
measure "files" \
-X PUT \
-H 'Content-Type: application/json' \
-H "x-rustpad-csrf: $CSRF" \
--data '{"access_token":null}' \
"$API_BASE/files"
echo
echo '=== ON DEMAND ==='
measure "history" \
-X POST \
-H 'Content-Type: application/json' \
-H "x-rustpad-csrf: $CSRF" \
--data '{"access_token":null}' \
"$API_BASE/history"
;;
workspace-note)
echo '=== INITIAL NOTE LOAD ==='
measure_to_file "note_info" "$INFO_BODY" "$API_BASE"
if [[ "$LAST_HTTP" != "200" ]]; then
echo "ERROR: note_info failed; cannot continue reliably." >&2
jq -r '.error // empty' "$INFO_BODY" 2>/dev/null >&2 || true
exit 1
fi
unlock_resource_if_needed "$INFO_BODY"
measure "editor_color" "$API_BASE/editor-color"
measure "favorite_status" "$BASE/api/auth/favorites/status?kind=note&slug=$NOTE_SLUG&workspace_slug=$WORKSPACE_SLUG"
measure "files" \
-X PUT \
-H 'Content-Type: application/json' \
-H "x-rustpad-csrf: $CSRF" \
--data '{"access_token":null}' \
"$API_BASE/files"
echo
echo '=== ON DEMAND ==='
measure "history" \
-X POST \
-H 'Content-Type: application/json' \
-H "x-rustpad-csrf: $CSRF" \
--data '{"access_token":null}' \
"$API_BASE/history"
;;
workspace)
echo '=== INITIAL WORKSPACE LOAD ==='
measure_to_file "workspace_info" "$INFO_BODY" "$API_BASE"
if [[ "$LAST_HTTP" != "200" ]]; then
echo "ERROR: workspace_info failed; cannot continue reliably." >&2
jq -r '.error // empty' "$INFO_BODY" 2>/dev/null >&2 || true
exit 1
fi
unlock_resource_if_needed "$INFO_BODY"
measure "workspace_open" \
-X POST \
-H 'Content-Type: application/json' \
-H "x-rustpad-csrf: $CSRF" \
--data '{"access_token":null}' \
"$API_BASE/open?q=&page=1&per_page=25"
;;
esac
echo
echo 'Note: WebSocket payloads are not included in these curl measurements.'
+2
View File
@@ -22,7 +22,9 @@ const MODULES: &[&str] = &[
"connection-state", "connection-state",
"editor-format", "editor-format",
"emoji-data", "emoji-data",
"emoji-groups",
"emoji-picker", "emoji-picker",
"emoji-shortcodes",
"image-alias", "image-alias",
"image-upload", "image-upload",
"i18n", "i18n",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -7,7 +7,7 @@
* See LICENSE file in repository root for details. * See LICENSE file in repository root for details.
*/ */
import { EMOJI_GROUPS } from "@rustpad/emoji-data"; import { EMOJI_GROUPS } from "@rustpad/emoji-groups";
import { t } from "@rustpad/i18n"; import { t } from "@rustpad/i18n";
const RECENTS_KEY = "rustpad:recent-emojis"; const RECENTS_KEY = "rustpad:recent-emojis";
@@ -153,4 +153,9 @@ export function bindEmojiPicker({ editor, details, search, categories, grid, emp
document.addEventListener("rustpad:languagechange", () => { document.addEventListener("rustpad:languagechange", () => {
if (details.open) render(); if (details.open) render();
}); });
if (details.open) {
render();
requestAnimationFrame(() => search.focus());
}
} }
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -7,7 +7,7 @@
* See LICENSE file in repository root for details. * See LICENSE file in repository root for details.
*/ */
import { EMOJI_SHORTCODES } from "@rustpad/emoji-data"; import { EMOJI_SHORTCODES } from "@rustpad/emoji-shortcodes";
import { t } from "@rustpad/i18n"; import { t } from "@rustpad/i18n";
import { parseImageAlias } from "@rustpad/image-alias"; import { parseImageAlias } from "@rustpad/image-alias";
+49 -14
View File
@@ -17,10 +17,8 @@ import { CollaborationRevisionGapError, CollaborationSession } from "@rustpad/co
import { copyText } from "@rustpad/clipboard"; import { copyText } from "@rustpad/clipboard";
import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links"; import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links";
import { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rustpad/editor-format"; import { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rustpad/editor-format";
import { bindEmojiPicker } from "@rustpad/emoji-picker";
import { updateImageAliasInLineBySource } from "@rustpad/image-alias"; import { updateImageAliasInLineBySource } from "@rustpad/image-alias";
import { previewEditingHost } from "@rustpad/preview-edit"; import { previewEditingHost } from "@rustpad/preview-edit";
import { bindPdfExport } from "@rustpad/pdf-export";
import { createRenderQueue } from "@rustpad/render-queue"; import { createRenderQueue } from "@rustpad/render-queue";
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles, unresolvedMarkdownFileAliases } from "@rustpad/markdown"; import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles, unresolvedMarkdownFileAliases } from "@rustpad/markdown";
import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session"; import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
@@ -270,12 +268,7 @@ export function startNoteEditor(adapter) {
documentTitle.textContent = info.title; documentTitle.textContent = info.title;
} }
globalColor = info.global_color || ""; noteColor = info.note_color || ""; globalColor = info.global_color || ""; noteColor = info.note_color || "";
if (getAuthToken()) { if (!getAuthToken()) noteColor = readGuestColor();
const colors = await adapter.loadColor(accountHeaders());
globalColor = colors.global_color || ""; noteColor = colors.note_color || "";
} else {
noteColor = readGuestColor();
}
updateMarkdownFiles(info.files || []); updateMarkdownFiles(info.files || []);
if (info.personal_editor_settings) { if (info.personal_editor_settings) {
compactToggle.checked = info.compact_view !== false; compactToggle.checked = info.compact_view !== false;
@@ -1633,7 +1626,7 @@ export function startNoteEditor(adapter) {
}); });
socket.connect(); socket.connect();
} }
bindIdentityDialog({ dialog: identityDialog, onIdentity: async (value, session) => { nickname = value; accountSession = session || await validateCurrentSession(); accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); await syncFavoriteButton(); if (info.protected && info.access_level === "none") passwordDialog.showModal(); else { loadFiles(); connect(); } } }); bindIdentityDialog({ dialog: identityDialog, onIdentity: async (value, session) => { nickname = value; accountSession = session || await validateCurrentSession(); accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && info.access_level === "none") passwordDialog.showModal(); else { loadFiles(); connect(); } } });
identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); }); identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
async function showSystemNotFound() { async function showSystemNotFound() {
try { try {
@@ -1667,7 +1660,6 @@ export function startNoteEditor(adapter) {
adapter.configureView?.(info); adapter.configureView?.(info);
applyUi({ write: true, replace: true }); applyUi({ write: true, replace: true });
updateCurrentUser(); updateCurrentUser();
await syncFavoriteButton();
if (info.protected && info.access_level === "none") passwordDialog.showModal(); if (info.protected && info.access_level === "none") passwordDialog.showModal();
else { loadFiles(); connect(); } else { loadFiles(); connect(); }
} catch (e) { } catch (e) {
@@ -2074,14 +2066,57 @@ export function startNoteEditor(adapter) {
}); });
bindFormatShortcuts(editor); bindFormatShortcuts(editor);
bindIndentationShortcuts(editor, { size: 2 }); bindIndentationShortcuts(editor, { size: 2 });
bindPdfExport({ const pdfExportArgs = {
button: document.querySelector("#export-pdf"), button: document.querySelector("#export-pdf"),
dialog: document.querySelector("#pdf-export-dialog"), dialog: document.querySelector("#pdf-export-dialog"),
form: document.querySelector("#pdf-export-form"), form: document.querySelector("#pdf-export-form"),
preview, preview,
title: () => document.querySelector("#document-title")?.textContent?.trim() || document.title, title: () => document.querySelector("#document-title")?.textContent?.trim() || document.title,
};
let pdfExportBound = false;
let pdfExportLoad = null;
pdfExportArgs.button?.addEventListener("click", async event => {
if (pdfExportBound) return;
event.stopImmediatePropagation();
pdfExportLoad ||= import("@rustpad/pdf-export").then(({ bindPdfExport }) => {
bindPdfExport(pdfExportArgs);
pdfExportBound = true;
});
try {
await pdfExportLoad;
if (!pdfExportArgs.dialog.open) pdfExportArgs.dialog.showModal();
} catch (error) {
pdfExportLoad = null;
console.error("Failed to load PDF export", error);
toast.danger(t("editor.pdfExportFailed", {}, "Could not prepare the PDF."), {
title: t("editor.exportPdf", {}, "Export PDF"),
});
}
});
const emojiPickerArgs = {
editor,
details: document.querySelector("#emoji-picker"),
search: document.querySelector("#emoji-search"),
categories: document.querySelector("#emoji-categories"),
grid: document.querySelector("#emoji-grid"),
empty: document.querySelector("#emoji-empty"),
};
let emojiPickerLoad = null;
const loadEmojiPicker = async () => {
if (emojiPickerLoad) return emojiPickerLoad;
emojiPickerLoad = import("@rustpad/emoji-picker").then(({ bindEmojiPicker }) => {
bindEmojiPicker(emojiPickerArgs);
}).catch(error => {
emojiPickerLoad = null;
throw error;
});
return emojiPickerLoad;
};
emojiPickerArgs.details?.addEventListener("toggle", () => {
if (!emojiPickerArgs.details.open) return;
void loadEmojiPicker().catch(error => console.error("Failed to load emoji picker", error));
}); });
bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") });
document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal());
document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close());
@@ -2259,7 +2294,7 @@ export function startNoteEditor(adapter) {
password = ""; password = "";
setPagePasswordInput.value = ""; setPagePasswordInput.value = "";
await loadNoteInfo(); await loadNoteInfo();
await syncFavoriteButton(); void syncFavoriteButton();
resourceUnlocked = false; resourceUnlocked = false;
socket?.stop(); socket?.stop();
loadFiles(); loadFiles();
@@ -2357,7 +2392,7 @@ export function startNoteEditor(adapter) {
document.querySelector("#open-password")?.focus(); document.querySelector("#open-password")?.focus();
} }
}); });
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); setAccessToken(adapter.access.kind, adapter.access.key, result.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; await loadNoteInfo(); await syncFavoriteButton(); loadFiles(); connect(); toast.success("Editing access has been unlocked.", { title: "Note unlocked" }); } catch (error) { document.querySelector("#password-error").textContent = error.message; toast.danger(error.message, { title: "Could not unlock note" }); } }); document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); setAccessToken(adapter.access.kind, adapter.access.key, result.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; await loadNoteInfo(); void syncFavoriteButton(); loadFiles(); connect(); toast.success("Editing access has been unlocked.", { title: "Note unlocked" }); } catch (error) { document.querySelector("#password-error").textContent = error.message; toast.danger(error.message, { title: "Could not unlock note" }); } });
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { if (info?.protected && !resourceUnlocked) { if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); return; } historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast.success("The selected revision is now the current version.", { title: "Version restored" }); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; toast.danger(e.message, { title: "Could not load version history" }); } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); }); const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { if (info?.protected && !resourceUnlocked) { if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); return; } historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast.success("The selected revision is now the current version.", { title: "Version restored" }); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; toast.danger(e.message, { title: "Could not load version history" }); } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast.danger(error.message, { title: "Could not delete note" }); } }); const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast.danger(error.message, { title: "Could not delete note" }); } });