"use strict"; const $ = id => document.getElementById(id); let authorization = null; let notes = []; let editing = null; function notify(message, error = false) { $("status").textContent = message; $("status").dataset.error = String(error); $("status").hidden = false; } async function api(path, { method = "GET", body, accept = "text/plain", includeHeaders = false } = {}, auth = authorization) { const headers = { Accept: accept }; if (auth) headers.Authorization = auth; const response = await fetch(path, { method, body, headers, credentials: "omit", cache: "no-store" }); if (!response.ok) { const messages = { 400: "Check the URL and settings. Expiry must be nonnegative and the view limit must be positive.", 401: "Sign in with the correct username and password.", 403: "Only the creator can change or delete this share.", 404: "This share does not exist, has expired, or has reached its view limit.", 413: "This share is too large for the server.", 422: "Check the form values and use whole numbers for expiry and views.", }; throw new Error(messages[response.status] || `The request failed (${response.status}). Please try again.`); } const data = accept === "application/json" ? await response.json() : await response.text(); return includeHeaders ? { data, headers: response.headers } : data; } async function busy(button, action) { button.disabled = true; try { await action(); } catch (error) { notify(error.message, true); } finally { button.disabled = false; } } function showPanel(id) { if (!["compose", "collection", "open"].includes(id)) id = "compose"; for (const panel of document.querySelectorAll(".panel")) panel.hidden = panel.id !== id; for (const link of document.querySelectorAll("[data-panel]")) { if (link.dataset.panel === id) link.setAttribute("aria-current", "page"); else link.removeAttribute("aria-current"); } history.replaceState(null, "", `#${id}`); } function isUrl(note) { return note.paste_type === "UrlV1"; } function sharePath(id, url) { return `${url ? "/to/" : "/"}${encodeURIComponent(id)}`; } function date(value) { if (value === null) return "—"; const parsed = new Date(value * 1000); return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toLocaleString(); } function cell(row, text) { const td = document.createElement("td"); td.textContent = text; row.append(td); return td; } function action(label, callback, className = "secondary") { const button = document.createElement("button"); button.type = "button"; button.textContent = label; button.className = className; button.addEventListener("click", () => busy(button, callback)); return button; } function renderNotes() { $("note-count").textContent = notes.length; $("note-rows").replaceChildren(); $("refresh").disabled = !authorization; const query = $("search").value.toLowerCase(); const filtered = notes.filter(note => [note.id, note.content, note.language || ""] .some(value => value.toLowerCase().includes(query))); $("collection-table").hidden = filtered.length === 0; $("collection-empty").hidden = filtered.length !== 0; $("collection-empty").textContent = !authorization ? "Sign in to see and manage your shares." : notes.length === 0 ? "Nothing here yet. Create a share while signed in to start your collection." : "No shares match your search."; for (const note of filtered) { const row = document.createElement("tr"); const name = cell(row, ""); name.append(action(note.id, () => openEntry(note), "link-button")); const type = document.createElement("small"); type.textContent = isUrl(note) ? "Short link" : note.language || "Plain text"; name.append(type); const content = cell(row, note.content.slice(0, 140)); content.className = "content-preview"; cell(row, `${note.views} / ${note.max_views ?? "∞"}`); cell(row, date(note.created_at)); cell(row, date(note.updated_at)); cell(row, note.expiration === null ? "Never" : date(note.expiration)); const controls = cell(row, ""); controls.className = "row-actions"; controls.append( action("Edit", () => editNote(note)), action("Delete", () => deleteNote(note), "danger"), ); $("note-rows").append(row); } } async function loadNotes() { const auth = authorization; if (!auth) return; const result = await api("/list", { accept: "application/json" }, auth); if (authorization !== auth) return; notes = result; renderNotes(); } function updateEditor() { const url = $("kind").value === "url"; $("kind").disabled = editing !== null; $("custom-id").disabled = editing !== null; $("new-note").hidden = editing === null; $("once-field").hidden = url || editing !== null; $("once").disabled = url || editing !== null; if (url || editing) $("once").checked = false; $("max-views").disabled = $("once").checked; if ($("once").checked) $("max-views").value = "1"; $("language-field").hidden = url; $("language").options[0].textContent = editing ? "Keep current language" : "Auto detect"; $("content-label").textContent = url ? "Destination URL" : "Your text or code"; $("content").rows = url ? 3 : 15; $("content").placeholder = url ? "https://example.com" : "Paste something worth sharing…"; $("editor-title").textContent = editing ? `Edit ${url ? "link" : "paste"} · ${editing.id}` : "Create a share"; $("save").textContent = editing ? "Save changes" : url ? "Create short link" : $("once").checked ? "Create one-time secret" : "Create share"; $("ownership-hint").textContent = authorization ? "Saved to your collection." : "Sign in to edit or delete this share later."; $("expires").placeholder = editing ? "Keep current expiry" : "Never"; $("edit-hint").hidden = editing === null; if (editing) $("edit-hint").textContent = `Current expiry: ${editing.expiration === null ? "never" : date(editing.expiration)}. Leave expiry blank to keep it. Views stay at ${editing.views}.`; } function resetEditor() { editing = null; $("language").querySelector("[data-current]")?.remove(); $("editor-form").reset(); $("share-result").hidden = true; updateEditor(); } function editNote(note) { resetEditor(); editing = note; $("kind").value = isUrl(note) ? "url" : "note"; $("custom-id").value = note.id; $("content").value = note.content; const language = note.language || ""; if (language && !Array.from($("language").options).some(option => option.value === language)) { const current = new Option(`${language} (current)`, language); current.dataset.current = ""; $("language").add(current); } $("language").value = language; $("max-views").value = note.max_views ?? ""; updateEditor(); showPanel("compose"); $("content").focus(); } async function deleteNote(note) { if (!window.confirm(`Permanently delete “${note.id}”?`)) return; await api(sharePath(note.id, false), { method: "DELETE" }); if (editing?.id === note.id) resetEditor(); await loadNotes(); notify(`Deleted ${note.id}.`); } function reference(value, url) { let id = value.trim(); if (/^(https?:\/\/|\/)/i.test(id)) { const parsed = new URL(id, location.origin); if (parsed.origin !== location.origin) throw new Error("Use an ID or a link from this Pastable server."); url = parsed.pathname.startsWith("/to/"); id = decodeURIComponent(parsed.pathname.slice(url ? 4 : 1)); } if (!id || id.includes("/") || ["gui", "list", "once", "to", "static"].includes(id)) { throw new Error("Enter a valid share ID or a full Pastable share URL."); } return { id, url }; } async function openPaste(id) { $("preview").replaceChildren(); $("preview").hidden = true; const highlighted = $("open-format").value === "html"; const { data: content, headers } = await api(sharePath(id, false), { accept: highlighted ? "text/html" : "text/plain", includeHeaders: true, }); const counter = document.createElement("p"); counter.className = "badge view-counter"; counter.textContent = `Views: ${headers.get("x-paste-views")} / ${headers.get("x-paste-max-views") ?? "∞"}`; $("preview").append(counter); if (highlighted) { const page = new DOMParser().parseFromString(content, "text/html"); const code = page.querySelector("main pre"); if (!code) throw new Error("The server returned an unexpected preview."); $("preview").append(document.importNode(code, true)); } else { const pre = document.createElement("pre"); pre.textContent = content; $("preview").append(pre); } $("preview").hidden = false; await loadNotes(); notify(`Opened ${id}. This read counts as a view.`); } async function openEntry(note) { $("open-id").value = note.id; $("open-kind").value = isUrl(note) ? "url" : "note"; updateOpen(); showPanel("open"); if (isUrl(note)) { window.open(sharePath(note.id, true), "_blank", "noopener,noreferrer"); notify("The short link opens in a new tab. Refresh your collection to update its view count."); } else { await openPaste(note.id); } } function updateOpen() { const url = $("open-kind").value === "url"; $("format-field").hidden = url; $("open-submit").textContent = url ? "Follow short link ↗" : "Open paste"; $("preview").hidden = true; } $("login-form").addEventListener("submit", event => { event.preventDefault(); busy(event.submitter, async () => { const username = $("username").value; const credentials = new TextEncoder().encode(`${username}:${$("password").value}`); const auth = `Basic ${btoa(Array.from(credentials, byte => String.fromCharCode(byte)).join(""))}`; const result = await api("/list", { accept: "application/json" }, auth); if (authorization && authorization !== auth) resetEditor(); authorization = auth; notes = result; $("password").value = ""; $("session-label").textContent = username; $("logout").hidden = false; $("account").open = false; renderNotes(); updateEditor(); notify(`Signed in as ${username}. New shares will belong to your account.`); }); }); $("logout").addEventListener("click", () => { authorization = null; notes = []; $("login-form").reset(); $("logout").hidden = true; $("session-label").textContent = "Anonymous"; $("preview").replaceChildren(); $("preview").hidden = true; resetEditor(); renderNotes(); notify("Signed out."); }); $("editor-form").addEventListener("submit", event => { event.preventDefault(); busy(event.submitter, async () => { const url = $("kind").value === "url"; const body = new URLSearchParams(); body.set(url ? "href" : "content", $("content").value); if (!url && $("language").value) body.set("language", $("language").value); if ($("expires").value !== "") body.set("expires", $("expires").value); if ($("max-views").value !== "") body.set("max_views", $("max-views").value); const requested = $("custom-id").value.trim(); let path = url ? "/to" : $("once").checked ? "/once" : ""; if (editing) path = sharePath(editing.id, url); else path = requested ? `${path}/${encodeURIComponent(requested)}` : path || "/"; const result = await api(path, { method: editing ? "PUT" : "POST", body }); const id = editing ? editing.id : result; const link = new URL(sharePath(id, url), location.origin).href; $("share-url").value = link; $("share-link").href = link; $("result-title").textContent = editing ? "Changes saved" : "Your share is ready"; $("share-result").hidden = false; await loadNotes(); if (editing) { editing = notes.find(note => note.id === id) || editing; $("expires").value = ""; updateEditor(); } notify(`${editing ? "Updated" : "Created"} ${id}.${requested && requested !== id ? " The requested ID was unavailable, so a new one was generated." : ""}`); }); }); $("copy-url").addEventListener("click", event => busy(event.currentTarget, async () => { if (!navigator.clipboard) { $("share-url").focus(); $("share-url").select(); notify("The link is selected. Copy it with your keyboard or context menu."); return; } await navigator.clipboard.writeText($("share-url").value); notify("Share link copied."); })); $("open-form").addEventListener("submit", event => { event.preventDefault(); busy(event.submitter, async () => { const ref = reference($("open-id").value, $("open-kind").value === "url"); $("open-kind").value = ref.url ? "url" : "note"; updateOpen(); if (ref.url) { window.open(sharePath(ref.id, true), "_blank", "noopener,noreferrer"); notify("The short link opens in a new tab. Following it counts as a view."); } else { await openPaste(ref.id); } }); }); for (const link of document.querySelectorAll("[data-panel]")) { link.addEventListener("click", event => { event.preventDefault(); showPanel(link.dataset.panel); }); } $("kind").addEventListener("change", updateEditor); $("once").addEventListener("change", updateEditor); $("new-note").addEventListener("click", resetEditor); $("open-kind").addEventListener("change", updateOpen); $("search").addEventListener("input", renderNotes); $("refresh").addEventListener("click", event => busy(event.currentTarget, loadNotes)); window.addEventListener("hashchange", () => showPanel(location.hash.slice(1))); showPanel(location.hash.slice(1)); renderNotes(); updateEditor();