More gui
This commit is contained in:
17 files changed
+1043
-31
No files matched your search
+353
@@ -0,0 +1,353 @@
|
||||
"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();
|
||||
Reference in new issue
Block a user