/* * 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 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"); let notesPage = 1; let notesSearchTimer; const notesViewKey = `rustpad:workspace:${slug}:notes-view`; let notesView = localStorage.getItem(notesViewKey) === "table" ? "table" : "grid"; let notesCache = []; 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 `Participants: ${Number(note.participant_count) || 0}Files: ${Number(note.file_count) || 0} (${formatBytes(note.file_size_bytes)})Revisions: ${Number(note.revision_count) || 0}`; } 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 ``; } function renderNotes(notes = notesCache) { notesCache = notes; setNotesView(notesView); if (!notes.length) { notesList.innerHTML = '

No notes yet.

'; return; } if (notesView === "table") { notesList.innerHTML = `
${notes.map(note => ` `).join("")}
NameCreated byParticipantsFilesRevisionsStatusUpdatedActions
${escapeHtml(note.title)} ${escapeHtml(note.created_by || "Unknown")} ${Number(note.participant_count) || 0} ${Number(note.file_count) || 0} (${formatBytes(note.file_size_bytes)}) ${Number(note.revision_count) || 0} ${note.protected ? 'Protected' : 'Unprotected'} ${formatDate(note.updated_at)} ${deleteButton(note, true)}
`; return; } notesList.innerHTML = notes.map(note => `

${escapeHtml(note.title)}

${note.protected ? 'Protected' : ''}
Created by: ${escapeHtml(note.created_by || "Unknown")}${noteStats(note)}Updated: ${formatDate(note.updated_at)}
${deleteButton(note)}
`).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 ? `Page ${meta.page} of ${meta.total_pages} · ${meta.total} notes` : ""; } 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); if (dialog.open) dialog.close(); } catch (e) { if (info?.protected || e.message.toLowerCase().includes("password")) { document.querySelector("#password-error").textContent = e.message; if (!dialog.open) dialog.showModal(); } 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; if (info.protected && info.access_level === "none") dialog.showModal(); else 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 = ""; openWorkspace(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } }); 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}?view=split&mode=markdown`)); } 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(); }); }); setNotesView(notesView); startAuthorizedWorkspace();