Files
rustpad/static/js/workspace.js
T
2026-08-05 09:53:28 +02:00

381 lines
17 KiB
JavaScript

/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();
import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { getNickname, getAccessToken, getAuthToken, getGuestId, setAccessToken } from "@rustpad/session";
import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
import { askConfirm } from "@rustpad/modal";
import { safeAppUrl } from "@rustpad/security";
import { toast } from "@rustpad/toast";
const parts = location.pathname.split("/").filter(Boolean);
const slug = parts[1];
let info;
const shareToken = new URLSearchParams(location.search).get("share");
let accessToken = shareToken || getAccessToken("workspace", slug);
let nickname = getNickname();
getGuestId();
const workspaceWatchClientId = `workspace_watch_${crypto.randomUUID()}`;
let workspaceWatchSocket;
let workspaceWatchPingTimer;
let workspaceWatchReconnectTimer;
let workspaceWatchIntentionalClose = false;
let workspaceLockedForPassword = false;
const dialog = document.querySelector("#password-dialog");
const identityDialog = document.querySelector("#identity-dialog");
const workspaceContent = document.querySelector("#workspace-content");
const notesList = document.querySelector("#notes-list");
const notesSearch = document.querySelector("#notes-search");
const notesPerPage = document.querySelector("#notes-per-page");
const notesPagination = document.querySelector("#notes-pagination");
const workspacePasswordForm = document.querySelector("#workspace-password-form");
const workspacePasswordInput = document.querySelector("#workspace-set-password");
const workspacePasswordError = document.querySelector("#workspace-password-error");
let notesPage = 1;
let notesSearchTimer;
const notesViewKey = `rustpad:workspace:${slug}:notes-view`;
let notesView = localStorage.getItem(notesViewKey) === "table" ? "table" : "grid";
let notesCache = [];
function updateWorkspacePasswordControl() {
workspacePasswordForm.hidden = Boolean(info?.protected || !info?.can_set_password);
}
function stopWorkspaceWatch() {
clearInterval(workspaceWatchPingTimer);
clearTimeout(workspaceWatchReconnectTimer);
workspaceWatchPingTimer = undefined;
workspaceWatchReconnectTimer = undefined;
if (workspaceWatchSocket) {
workspaceWatchIntentionalClose = true;
workspaceWatchSocket.close();
workspaceWatchSocket = undefined;
}
}
function lockWorkspaceForPassword(message = "A password was set for this workspace. Enter it to continue.") {
workspaceLockedForPassword = true;
info = { ...info, protected: true, access_level: "none", can_set_password: false };
stopWorkspaceWatch();
workspaceContent.hidden = true;
document.querySelector("#password-error").textContent = message;
if (!dialog.open) dialog.showModal();
document.querySelector("#open-password")?.focus();
}
function scheduleWorkspaceWatchReconnect() {
clearTimeout(workspaceWatchReconnectTimer);
if (workspaceLockedForPassword || !nickname) return;
workspaceWatchReconnectTimer = window.setTimeout(connectWorkspaceWatch, 1500);
}
function connectWorkspaceWatch() {
if (workspaceLockedForPassword || !nickname) return;
if (workspaceWatchSocket?.readyState === WebSocket.OPEN || workspaceWatchSocket?.readyState === WebSocket.CONNECTING) return;
clearTimeout(workspaceWatchReconnectTimer);
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${protocol}//${location.host}/ws/watch/workspace/${encodeURIComponent(slug)}`);
workspaceWatchSocket = socket;
workspaceWatchIntentionalClose = false;
socket.addEventListener("open", () => {
if (socket !== workspaceWatchSocket) return;
socket.send(JSON.stringify({
type: "authenticate",
access_token: accessToken || null,
client_id: workspaceWatchClientId,
}));
clearInterval(workspaceWatchPingTimer);
workspaceWatchPingTimer = window.setInterval(() => {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "ping", nonce: Date.now() }));
}, 10000);
});
socket.addEventListener("message", event => {
if (socket !== workspaceWatchSocket) return;
let message;
try { message = JSON.parse(event.data); } catch { return; }
if (message.type === "password_required") {
workspaceWatchIntentionalClose = true;
lockWorkspaceForPassword();
return;
}
if (message.type === "password_changed") {
workspaceWatchIntentionalClose = true;
socket.close();
return;
}
if (message.type === "error") {
workspaceWatchIntentionalClose = true;
socket.close();
if (/password/i.test(message.message || "")) lockWorkspaceForPassword(message.message);
}
});
socket.addEventListener("close", () => {
if (socket !== workspaceWatchSocket) return;
clearInterval(workspaceWatchPingTimer);
workspaceWatchPingTimer = undefined;
workspaceWatchSocket = undefined;
if (!workspaceWatchIntentionalClose) scheduleWorkspaceWatchReconnect();
});
socket.addEventListener("error", () => {
if (socket.readyState !== WebSocket.CLOSING && socket.readyState !== WebSocket.CLOSED) socket.close();
});
}
function escapeHtml(v) { const e = document.createElement("div"); e.textContent = v; return e.innerHTML; }
function formatBytes(value) {
const bytes = Math.max(0, Number(value) || 0);
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"];
let amount = bytes;
let unit = -1;
do { amount /= 1024; unit++; } while (amount >= 1024 && unit < units.length - 1);
return `${amount >= 10 ? amount.toFixed(0) : amount.toFixed(1)} ${units[unit]}`;
}
function noteStats(note) {
return `<span>Participants: ${Number(note.participant_count) || 0}</span><span>Files: ${Number(note.file_count) || 0} (${formatBytes(note.file_size_bytes)})</span><span>Revisions: ${Number(note.revision_count) || 0}</span>`;
}
function formatDate(value) {
if (value == null || value === "") return "—";
let raw = String(value).trim();
if (/^\d+$/.test(raw)) { const number = Number(raw); raw = raw.length <= 10 ? number * 1000 : number; }
else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(raw)) raw = raw.replace(" ", "T") + "Z";
const date = new Date(raw);
return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString("pl-PL");
}
function setNotesView(view) {
notesView = view === "table" ? "table" : "grid";
localStorage.setItem(notesViewKey, notesView);
notesList.classList.toggle("notes-grid", notesView === "grid");
notesList.classList.toggle("notes-table", notesView === "table");
document.querySelectorAll("[data-notes-view]").forEach(button => {
const active = button.dataset.notesView === notesView;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
}
function deleteButton(note, inline = false) {
const disabled = note.protected;
const classes = `note-delete-button${inline ? " note-delete-button--inline" : ""}`;
const reason = disabled ? "Protected notes cannot be deleted" : `Delete ${note.title}`;
return `<button class="${classes}" data-delete-note="${escapeHtml(note.slug)}" data-note-title="${escapeHtml(note.title)}" ${disabled ? "disabled" : ""} title="${escapeHtml(reason)}">Delete</button>`;
}
function renderNotes(notes = notesCache) {
notesCache = notes;
setNotesView(notesView);
if (!notes.length) {
notesList.innerHTML = '<p class="empty">No notes yet.</p>';
return;
}
if (notesView === "table") {
notesList.innerHTML = `<div class="notes-table-scroll"><table><thead><tr><th>Name</th><th>Created by</th><th>Participants</th><th>Files</th><th>Revisions</th><th>Status</th><th>Updated</th><th class="notes-table-actions">Actions</th></tr></thead><tbody>${notes.map(note => `
<tr>
<td><a class="note-table-link" href="${escapeHtml(safeAppUrl(note.url))}">${escapeHtml(note.title)}</a></td>
<td class="note-author">${escapeHtml(note.created_by || "Unknown")}</td>
<td>${Number(note.participant_count) || 0}</td>
<td>${Number(note.file_count) || 0} <span class="note-status">(${formatBytes(note.file_size_bytes)})</span></td>
<td>${Number(note.revision_count) || 0}</td>
<td>${note.protected ? '<span class="protect-badge">Protected</span>' : '<span class="note-status">Unprotected</span>'}</td>
<td>${formatDate(note.updated_at)}</td>
<td class="notes-table-actions">${deleteButton(note, true)}</td>
</tr>`).join("")}</tbody></table></div>`;
return;
}
notesList.innerHTML = notes.map(note => `
<article class="note-card-wrap">
<a class="note-card" href="${escapeHtml(safeAppUrl(note.url))}">
<div class="note-card-title"><h3>${escapeHtml(note.title)}</h3>${note.protected ? '<span class="protect-badge">Protected</span>' : ''}</div>
<div class="note-card-meta"><span>Created by: ${escapeHtml(note.created_by || "Unknown")}</span>${noteStats(note)}<span>Updated: ${formatDate(note.updated_at)}</span></div>
</a>
${deleteButton(note)}
</article>`).join("");
}
async function showSystemNotFound() {
try {
const response = await fetch(`${location.pathname.replace(/\/$/, "")}/__not_found__`, {
cache: "no-store",
credentials: "same-origin",
});
const html = await response.text();
document.open();
document.write(html);
document.close();
} catch {
document.body.textContent = "404 Not Found";
}
}
function renderNotesPagination(meta) {
notesPagination.innerHTML = meta.total ? `<button type="button" data-page="${meta.page - 1}" ${meta.page <= 1 ? "disabled" : ""}>Previous</button><span>Page ${meta.page} of ${meta.total_pages} · ${meta.total} notes</span><button type="button" data-page="${meta.page + 1}" ${meta.page >= meta.total_pages ? "disabled" : ""}>Next</button>` : "";
}
async function openWorkspace() {
try {
const params = new URLSearchParams({ q: notesSearch.value.trim(), page: String(notesPage), per_page: notesPerPage.value });
const data = await api(`/api/workspaces/${encodeURIComponent(slug)}/open?${params}`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null }) });
info = data.workspace;
document.querySelector("#workspace-title").textContent = info.title;
document.querySelector("#workspace-url").textContent = location.pathname;
document.title = `${info.title} · RustPad`;
notesPage = data.pagination.page;
notesCache = data.notes;
renderNotes();
renderNotesPagination(data.pagination);
updateWorkspacePasswordControl();
workspaceLockedForPassword = false;
workspaceContent.hidden = false;
if (dialog.open) dialog.close();
connectWorkspaceWatch();
} catch (e) {
if (info?.protected || e.message.toLowerCase().includes("password")) {
lockWorkspaceForPassword(e.message);
} else if (e.status === 403 || e.status === 404) {
await showSystemNotFound();
} else document.querySelector("#workspace-error").textContent = e.message;
}
}
async function init() {
try {
const headers = accessToken && accessToken !== "cookie" ? { Authorization: `Bearer ${accessToken}` } : {};
info = await api(`/api/workspaces/${encodeURIComponent(slug)}`, { headers });
document.querySelector("#workspace-title").textContent = info.title;
document.querySelector("#workspace-url").textContent = location.pathname;
updateWorkspacePasswordControl();
if (info.protected && info.access_level === "none") lockWorkspaceForPassword("Enter the workspace password to continue.");
else await openWorkspace();
} catch (e) {
if (e.status === 403 || e.status === 404) await showSystemNotFound();
else document.querySelector("#workspace-error").textContent = e.message;
}
}
document.querySelector("#password-form").addEventListener("submit", async e => {
e.preventDefault();
try {
const password = document.querySelector("#open-password").value;
const result = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "workspace", slug, password }) });
setAccessToken("workspace", slug, result.granted);
accessToken = getAccessToken("workspace", slug);
document.querySelector("#open-password").value = "";
document.querySelector("#password-error").textContent = "";
await openWorkspace();
} catch (error) { document.querySelector("#password-error").textContent = error.message; }
});
workspacePasswordForm.addEventListener("submit", async event => {
event.preventDefault();
const password = workspacePasswordInput.value;
workspacePasswordError.textContent = "";
if (password.length < 8) {
workspacePasswordError.textContent = "Password must contain at least 8 characters.";
return;
}
const submit = workspacePasswordForm.querySelector('button[type="submit"]');
submit.disabled = true;
try {
await api(`/api/workspaces/${encodeURIComponent(slug)}/password`, {
method: "POST",
body: JSON.stringify({ password, client_id: workspaceWatchClientId }),
});
const result = await api("/api/access-token", {
method: "POST",
body: JSON.stringify({ kind: "workspace", slug, password }),
});
setAccessToken("workspace", slug, result.granted);
accessToken = getAccessToken("workspace", slug);
workspacePasswordInput.value = "";
toast("Workspace password set");
await openWorkspace();
} catch (error) {
workspacePasswordError.textContent = error.message;
} finally {
submit.disabled = false;
}
});
document.querySelector("#new-note-button").addEventListener("click", () => document.querySelector("#note-dialog").showModal());
document.querySelector("#cancel-note").addEventListener("click", () => document.querySelector("#note-dialog").close());
document.querySelector("#note-form").addEventListener("submit", async e => {
e.preventDefault();
const error = document.querySelector("#note-error"); error.textContent = "";
try {
const note = await api(`/api/workspaces/${encodeURIComponent(slug)}/notes`, {
method: "POST",
body: JSON.stringify({ name: document.querySelector("#note-name").value, access_token: accessToken || null, protect: document.querySelector("#note-protect").checked, created_by: nickname || null })
});
location.assign(safeAppUrl(note.url));
} catch (err) { error.textContent = err.message; }
});
notesList.addEventListener("click", async event => {
const button = event.target.closest("[data-delete-note]");
if (!button) return;
const title = button.dataset.noteTitle;
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)}`, {
method: "DELETE", body: JSON.stringify({ access_token: accessToken || null })
});
toast("Note deleted");
await openWorkspace();
} catch (error) { toast(error.message); button.disabled = false; }
});
document.querySelectorAll("[data-notes-view]").forEach(button => button.addEventListener("click", () => {
if (button.dataset.notesView === notesView) return;
notesView = button.dataset.notesView;
renderNotes();
}));
notesSearch.addEventListener("input", () => { clearTimeout(notesSearchTimer); notesSearchTimer = setTimeout(() => { notesPage = 1; openWorkspace(); }, 250); });
notesPerPage.addEventListener("change", () => { notesPage = 1; openWorkspace(); });
notesPagination.addEventListener("click", event => { const button = event.target.closest("[data-page]"); if (!button || button.disabled) return; notesPage = Number(button.dataset.page) || 1; openWorkspace(); });
document.querySelector("#copy-workspace-link").addEventListener("click", async () => {
try { await copyText(new URL(location.pathname, location.origin).href); toast("Link copied"); }
catch (e) { toast(e.message); }
});
async function startAuthorizedWorkspace() {
const session = await validateCurrentSession();
nickname = session?.nickname || getNickname();
if (!nickname) {
workspaceContent.hidden = true;
if (!identityDialog.open) identityDialog.showModal();
return;
}
accessToken = shareToken || getAccessToken("workspace", slug);
workspaceContent.hidden = false;
await init();
}
bindIdentityDialog({
dialog: identityDialog,
onIdentity: async value => {
nickname = value;
accessToken = shareToken || getAccessToken("workspace", slug);
workspaceContent.hidden = false;
await init();
},
});
identityDialog.addEventListener("close", () => {
if (!nickname) queueMicrotask(() => {
workspaceContent.hidden = true;
if (!identityDialog.open) identityDialog.showModal();
});
});
dialog.addEventListener("cancel", event => {
if (workspaceLockedForPassword) {
event.preventDefault();
document.querySelector("#open-password")?.focus();
}
});
window.addEventListener("beforeunload", stopWorkspaceWatch);
setNotesView(notesView);
startAuthorizedWorkspace();