line multi authors

This commit is contained in:
Mateusz Gruszczyński
2026-07-25 18:05:15 +02:00
parent 4c4c15495d
commit 1fcd6866f4
6 changed files with 158 additions and 77 deletions
+23 -40
View File
@@ -2,13 +2,14 @@ import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();
import { api } from "@rustpad/api";
import { applyAuthorshipEdit, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship } from "@rustpad/authorship";
import { applyAuthorshipEdit, authorshipOwners, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship } from "@rustpad/authorship";
import { copyText } from "@rustpad/clipboard";
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
import { bindEmojiPicker } from "@rustpad/emoji-picker";
import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown";
import { prepareImageFile } from "@rustpad/image-upload";
import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
import { bindIdentityDialog } from "@rustpad/auth-ui";
import { bindNoteFiles } from "@rustpad/note-files";
import { PadSocket } from "@rustpad/socket";
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
@@ -20,7 +21,8 @@ let unreadChat = 0;
const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker");
const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken("pad", slug, shareToken);
let accessToken = shareToken || getAuthToken() || getAccessToken("pad", slug), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "";
const lineToggle = document.querySelector("#line-numbers-toggle"); lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off";
const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off";
previewLineToggle.checked = localStorage.getItem("rustpad:preview-line-numbers") === "on";
compactToggle.checked = localStorage.getItem("rustpad:compact") !== "off";
fontFamily.value = localStorage.getItem("rustpad:font-family") || "mono";
fontSize.value = localStorage.getItem("rustpad:font-size") || "14";
@@ -48,7 +50,10 @@ async function renderCodeHighlight() { const nodes = preview.querySelectorAll('p
function renderGutter() {
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
const lines = Array.from({ length: lineCount });
const authorsByLine = lineAuthors(editor.value, authorship);
const showAuthorship = authorshipOwners(authorship).length > 1;
const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : [];
authorshipLayer.hidden = !showAuthorship;
ownerLabels.hidden = !showAuthorship;
const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, paddingTop = parseFloat(style.paddingTop) || 24, paddingBottom = parseFloat(style.paddingBottom) || 24;
gutter.style.paddingTop = `${paddingTop}px`; gutter.style.paddingBottom = `${paddingBottom}px`; gutter.style.lineHeight = `${lineHeight}px`;
gutter.innerHTML = lines.map((_, i) => `<div style="height:${lineHeight}px">${i + 1}</div>`).join("");
@@ -60,8 +65,10 @@ function renderGutter() {
const badges = authors.map(owner => `<span class="owner-label" style="--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`).join("");
return `<span class="owner-line" style="top:${top}px;--owner:${colorFor(authors[0])}"></span><span class="owner-label-group" style="top:${top}px">${badges}</span>`;
}).join("");
renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
document.body.classList.toggle("hide-line-numbers", !lineToggle.checked);
if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
else authorshipLayer.replaceChildren();
document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked);
document.body.classList.toggle("hide-preview-line-numbers", !previewLineToggle.checked);
}
function escapeHtml(v) { return String(v).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[c])); } function formatDate(value) { const raw = String(value ?? "").trim(); let normalized = raw; if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T") + ":00"; else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T"); else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized)) normalized = normalized.replace(" ", "T") + "Z"; const date = new Date(normalized); return Number.isNaN(date.getTime()) ? raw : date.toLocaleString("pl-PL", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); }
@@ -143,22 +150,21 @@ function render() { if (uiState.mode === "markdown") { preview.classList.remove(
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 previous = editor.value, start = editor.selectionStart, end = editor.selectionEnd, direction = editor.selectionDirection, scrollTop = editor.scrollTop, scrollLeft = editor.scrollLeft; const mapped = mapSelectionThroughEdit(previous, content, start, end); applyingRemote = true; editor.value = content; authorship = parseAuthorship(content, ownerMap); previousContent = content; editor.setSelectionRange(mapped.start, mapped.end, direction); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; applyingRemote = false; render(); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; authorshipLayer.scrollTop = scrollTop; authorshipLayer.scrollLeft = scrollLeft; }
async function loadFiles({ open = false } = {}) {
try {
const files = await api(`/api/pads/${encodeURIComponent(slug)}/files`, { method: "PUT", body: JSON.stringify({ access_token: accessToken || null }) });
const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0);
document.querySelector("#footer-files").textContent = `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`;
document.querySelector("#files-list").innerHTML = files.length ? files.map(file => `<div class="file-row" data-file-row="${file.id}"><div class="file-row-main"><div class="file-name">${escapeHtml(file.filename)}</div><div class="file-meta">${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)} · ${formatDate(file.created_at)} · <span class="file-flag${file.is_attached ? "" : " detached"}">${file.is_attached ? "in note" : "removed from content"}</span></div></div><div class="file-actions"><button data-show-file-code="link" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Link</button><button data-show-file-code="markdown" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Markdown</button><button data-show-file-code="html" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">HTML</button>${info?.can_delete_files ? `<button class="file-delete" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>` : ""}</div><div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button data-copy-generated>Copy</button></div></div>`).join("") : '<p class="dialog-copy">No files uploaded.</p>';
if (open) document.querySelector("#files-dialog").showModal();
} catch (error) { if (open) toast(error.message); }
}
const { loadFiles } = bindNoteFiles({
editor, toast, getAccessToken: () => accessToken, canDelete: () => Boolean(info?.can_delete_files),
endpoints: {
list: `/api/pads/${encodeURIComponent(slug)}/files`,
upload: `/api/pads/${encodeURIComponent(slug)}/files`,
remove: fileId => `/api/pads/${encodeURIComponent(slug)}/files/${encodeURIComponent(fileId)}`,
},
});
function connect() { socket?.stop(); socket = new PadSocket({ slug, password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), 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" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, 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(); updateCurrentUser(); await loadPadInfo(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } });
identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
async function initialize() { try { await loadPadInfo(); document.title = `${info.title} · RustPad`; publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); applyUi({ write: true, replace: true }); if (!nickname) { identityDialog.showModal(); return; } updateCurrentUser(); if (info.protected && !accessToken) 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.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(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); 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 });
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(); }); previewLineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:preview-line-numbers", previewLineToggle.checked ? "on" : "off"); renderGutter(); alignPreviewLineNumbers(preview); }); 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); 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("#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(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); 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({ access_token: accessToken || 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({ access_token: accessToken || 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); } });
roomDetails.addEventListener("toggle", () => { if (roomDetails.open) { clearUnread(); chatInput.focus(); if ("Notification" in window && Notification.permission === "default") Notification.requestPermission().catch(() => { }); } });
document.addEventListener("visibilitychange", () => { if (!document.hidden && roomDetails.open) clearUnread(); });
@@ -176,27 +182,4 @@ userColorPicker.addEventListener("input", () => {
editor.addEventListener("keydown", continueIndentation); editor.addEventListener("scroll", () => { gutter.scrollTop = editor.scrollTop; authorshipLayer.scrollTop = editor.scrollTop; authorshipLayer.scrollLeft = editor.scrollLeft; renderGutter(); }); editor.addEventListener("input", () => { const nextContent = editor.value; authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner()); previousContent = nextContent; render(); if (applyingRemote) return; clearTimeout(saveTimer); document.querySelector("#save-state").textContent = "Saving…"; saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250); });
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "pad", slug, password }) }); accessToken = result.access_token; setAccessToken("pad", slug, accessToken); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } });
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { 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 api(`/api/pads/${encodeURIComponent(slug)}/history`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null }) }); 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 api(`/api/pads/${encodeURIComponent(slug)}/restore`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null, revision_id: r.id }) }); toast("Version restored"); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
document.querySelector("#upload-button").addEventListener("click", () => document.querySelector("#file-input").click()); document.querySelector("#file-input").addEventListener("change", async e => { let file = e.target.files[0]; if (!file) return; if (file.type.startsWith("image/")) { file = await prepareImageFile(file); if (!file) { e.target.value = ""; return; } } const form = new FormData(); form.append("access_token", accessToken || ""); form.append("file", file); try { const result = await api(`/api/pads/${encodeURIComponent(slug)}/files`, { method: "POST", body: form, headers: {} }); const image = file.type.startsWith("image/"); const text = image ? `![${file.name}](${result.url})` : `[${file.name}](${result.url})`; editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end"); editor.dispatchEvent(new Event("input")); toast("File uploaded"); loadFiles(); } catch (err) { toast(err.message); } e.target.value = ""; });
document.querySelector("#files-button").addEventListener("click", () => loadFiles({ open: true }));
document.querySelector("#footer-files").addEventListener("click", () => loadFiles({ open: true }));
document.querySelector("#close-files").addEventListener("click", () => document.querySelector("#files-dialog").close());
document.querySelector("#files-list").addEventListener("click", async event => {
const showButton = event.target.closest("[data-show-file-code]");
if (showButton) {
const row = showButton.closest(".file-row"), panel = row.querySelector(".file-code"), output = panel.querySelector("textarea");
const absolute = new URL(showButton.dataset.url, location.origin).href;
let text = absolute;
if (showButton.dataset.showFileCode === "markdown") text = showButton.dataset.mime?.startsWith("image/") ? `![${showButton.dataset.name}](${absolute})` : `[${showButton.dataset.name}](${absolute})`;
if (showButton.dataset.showFileCode === "html") text = (showButton.dataset.mime || "").startsWith("image/") ? `<img src="${absolute}" alt="${showButton.dataset.name}">` : `<a href="${absolute}">${showButton.dataset.name}</a>`;
output.value = text; panel.hidden = false; output.focus(); output.select(); return;
}
const copyButton = event.target.closest("[data-copy-generated]");
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;
try { await api(`/api/pads/${encodeURIComponent(slug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`, { method: "DELETE", headers: getAuthToken() ? { Authorization: `Bearer ${getAuthToken()}` } : {}, body: JSON.stringify({ access_token: accessToken || null }) }); toast("File deleted"); await loadFiles(); } catch (error) { toast(error.message); } return;
}
});
initialize();