Files
pastable2/static/gui.js
T
2026-11-14 18:20:29 +01:00

354 lines
14 KiB
JavaScript

"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();