354 lines
14 KiB
JavaScript
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();
|