/* * 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 { isResourceAccessError, 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 (isResourceAccessError(message.message)) lockWorkspaceForPassword(message.message); else document.querySelector("#workspace-error").textContent = message.message || "Workspace connection error"; } }); 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 `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 = `| Name | Created by | Participants | Files | Revisions | Status | Updated | Actions |
|---|---|---|---|---|---|---|---|
| ${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)} |