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

308 lines
12 KiB
JavaScript

"use strict";
const $ = id => document.getElementById(id);
let authorization = null;
let notes = [];
let editing = null;
function notify(message, error = false) {
const target = $("share-result").open ? $("result-feedback") : $("status");
target.textContent = message;
target.dataset.error = String(error);
target.hidden = false;
}
async function api(path, { method = "GET", body, accept = "text/plain" } = {}, 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.`);
}
return accept === "application/json" ? response.json() : response.text();
}
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, "");
const link = document.createElement("a");
link.href = sharePath(note.id, isUrl(note));
link.textContent = note.id;
name.append(link);
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").close();
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 };
}
$("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";
resetEditor();
renderNotes();
notify("Signed out.");
});
$("editor-form").addEventListener("submit", event => {
event.preventDefault();
busy(event.submitter, async () => {
const current = editing;
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 (current) path = sharePath(current.id, url);
else path = requested ? `${path}/${encodeURIComponent(requested)}` : path || "/";
const result = await api(path, { method: current ? "PUT" : "POST", body });
const id = current ? current.id : result;
const link = new URL(sharePath(id, url), location.origin).href;
$("share-url").value = link;
$("share-link").href = link;
$("result-title").textContent = current ? "Changes saved" : "Your share is ready";
const message = `${current ? "Updated" : "Created"} ${id}.${requested && requested !== id ? " The requested ID was unavailable, so a new one was generated." : ""}`;
$("result-feedback").textContent = message;
$("result-feedback").dataset.error = "false";
$("result-feedback").hidden = false;
$("share-result").showModal();
$("share-url").focus();
$("share-url").select();
await loadNotes();
if (current && editing === current) {
editing = notes.find(note => note.id === id) || editing;
$("expires").value = "";
updateEditor();
}
});
});
$("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();
try {
const ref = reference($("open-id").value, $("open-kind").value === "url");
location.assign(sharePath(ref.id, ref.url));
} catch (error) {
notify(error.message, true);
}
});
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);
$("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();