From a3067b011d305668fe11144536cccd282c9ac385 Mon Sep 17 00:00:00 2001 From: maurice Date: Sat, 14 Nov 2026 18:25:54 +0100 Subject: [PATCH] Fixes --- README.md | 8 ++-- src/routes/create.rs | 2 +- src/routes/get_note.rs | 17 +++++-- static/app.css | 38 ++++++++++----- static/gui.js | 104 ++++++++++++----------------------------- static/paste.js | 22 +++++++++ templates/gui.html | 34 +++++++------- templates/note.html | 8 +++- 8 files changed, 123 insertions(+), 110 deletions(-) create mode 100644 static/paste.js diff --git a/README.md b/README.md index c5c4109..a96c62e 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,11 @@ Route handlers can access the shared database pool with the `State(pool): State< ## Browser app -Open `/gui` for the browser workspace. Create text pastes, one-time secrets, and short links; choose custom IDs, languages, expiry, and view limits; and open shares with syntax highlighting or as plain text. +Open `/gui` for the browser workspace. Create text pastes, one-time secrets, and short links; choose custom IDs, languages, expiry, and view limits. After saving, a dialog prominently displays the returned link for copying or opening. Opening a share navigates directly to its page or redirect. The language dropdown is populated from irosashi's supported grammars. The home page renders this README as HTML and shares the browser app's logo, navigation, and styling. -Sign in with HTTP Basic credentials to browse, filter, edit, and delete your own shares. A new username creates an account. Credentials stay in page memory and are cleared on sign-out or reload. The app uses the existing API without page reloads. +Sign in with HTTP Basic credentials to browse, filter, edit, and delete your own shares. A new username creates an account. Credentials stay in page memory and are cleared on sign-out or reload. Creating and managing shares uses the existing API without page reloads. Opening a paste or following a short link counts as a view. Listing records and loading them into the editor do not. Updates preserve omitted options and view counters. @@ -104,7 +104,9 @@ curl --request POST 'http://localhost:3000/once/my-secret' \ **Returns:** the paste as HTML or plain text, based on the `Accept` header. The default is plain text. HTML uses the stored language for syntax highlighting, falling back to plain text for missing or unknown languages. -HTML and GUI previews show the view counter, including the current read, and the maximum when set. Both representations include `X-Paste-Views` and, when limited, `X-Paste-Max-Views` response headers, so displaying the counter does not require another read. +HTML paste pages show the view counter, including the current read, and the maximum when set. HTML and plain-text responses include `X-Paste-Views` and, when limited, `X-Paste-Max-Views` response headers, so displaying the counter does not require another read. + +The HTML page includes a **Copy raw code** button that preserves the original content and whitespace without another request or view. If clipboard access is unavailable, the raw text is selected for manual copying. Each successful read increments `views`. The final permitted read returns the content and deletes the paste. Missing, expired, and exhausted pastes return `404`. Expired and exhausted records are also cleaned up in the background every minute. diff --git a/src/routes/create.rs b/src/routes/create.rs index c16c583..6b5aea9 100644 --- a/src/routes/create.rs +++ b/src/routes/create.rs @@ -43,7 +43,7 @@ pub async fn create( .fetch_optional(db) .await .map_err(db_error)?; - + if let Some(id) = created { return Ok(id); } diff --git a/src/routes/get_note.rs b/src/routes/get_note.rs index 9f5d0ee..d2f8194 100644 --- a/src/routes/get_note.rs +++ b/src/routes/get_note.rs @@ -5,6 +5,7 @@ use axum::{ http::{HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, }; +use base64::{Engine, engine::general_purpose::STANDARD}; use irosashi::{CodeToHtmlOptions, Highlighter}; use sqlx::SqlitePool; @@ -17,6 +18,7 @@ use super::{db_error, return_html}; pub struct NotePage { paste: Paste, code: String, + raw_code: String, } pub async fn get_note( @@ -24,10 +26,12 @@ pub async fn get_note( Path(id): Path, headers: HeaderMap, ) -> Result { - let paste = Paste::read(&db, &id, PasteType::TextV1) + let Some(paste) = Paste::read(&db, &id, PasteType::TextV1) .await .map_err(db_error)? - .ok_or(StatusCode::NOT_FOUND)?; + else { + return Ok((StatusCode::NOT_FOUND, "Note not found").into_response()); + }; let mut metadata = HeaderMap::new(); metadata.insert("x-paste-views", HeaderValue::from(paste.views)); @@ -46,9 +50,16 @@ pub async fn get_note( ), ) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - NotePage { paste, code }.into_response() + let raw_code = STANDARD.encode(&paste.content); + NotePage { + paste, + code, + raw_code, + } + .into_response() } else { paste.content.into_response() }; + Ok((metadata, response).into_response()) } diff --git a/static/app.css b/static/app.css index 424dccf..74fbfad 100644 --- a/static/app.css +++ b/static/app.css @@ -310,15 +310,6 @@ button:hover { color: #923e31; } -.link-button { - padding: 0; - background: transparent; - color: var(--accent); - text-decoration: underline; - text-align: left; - overflow-wrap: anywhere; -} - button:disabled { opacity: 0.55; cursor: wait; @@ -376,8 +367,33 @@ hr { color: #7f3022; } -.result, .preview { - margin-top: 1.25rem; +dialog.result { + box-sizing: border-box; + width: min(36rem, calc(100% - 2rem)); + max-height: calc(100vh - 2rem); + box-shadow: 0 1.5rem 5rem rgb(20 35 20 / 20%); +} + +dialog::backdrop { + background: rgb(20 35 20 / 45%); + backdrop-filter: blur(3px); +} + +.result h2 { + font-size: 1.75rem; +} + +.result #share-url { + font-family: ui-monospace, monospace; +} + +.result-actions { + justify-content: space-between; + margin-block: 1.5rem; +} + +.result [data-error="true"] { + color: #7f3022; } .result p:last-child { diff --git a/static/gui.js b/static/gui.js index e3b658d..fb44f39 100644 --- a/static/gui.js +++ b/static/gui.js @@ -6,12 +6,13 @@ let notes = []; let editing = null; function notify(message, error = false) { - $("status").textContent = message; - $("status").dataset.error = String(error); - $("status").hidden = 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", includeHeaders = false } = {}, auth = authorization) { +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" }); @@ -26,8 +27,7 @@ async function api(path, { method = "GET", body, accept = "text/plain", includeH }; 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; + return accept === "application/json" ? response.json() : response.text(); } async function busy(button, action) { @@ -96,7 +96,10 @@ function renderNotes() { for (const note of filtered) { const row = document.createElement("tr"); const name = cell(row, ""); - name.append(action(note.id, () => openEntry(note), "link-button")); + 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); @@ -154,7 +157,7 @@ function resetEditor() { editing = null; $("language").querySelector("[data-current]")?.remove(); $("editor-form").reset(); - $("share-result").hidden = true; + $("share-result").close(); updateEditor(); } @@ -199,53 +202,6 @@ function reference(value, 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 () => { @@ -272,8 +228,6 @@ $("logout").addEventListener("click", () => { $("login-form").reset(); $("logout").hidden = true; $("session-label").textContent = "Anonymous"; - $("preview").replaceChildren(); - $("preview").hidden = true; resetEditor(); renderNotes(); notify("Signed out."); @@ -282,6 +236,7 @@ $("logout").addEventListener("click", () => { $("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); @@ -290,22 +245,27 @@ $("editor-form").addEventListener("submit", event => { 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); + if (current) path = sharePath(current.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 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 = editing ? "Changes saved" : "Your share is ready"; - $("share-result").hidden = false; + $("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 (editing) { + if (current && editing === current) { 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." : ""}`); }); }); @@ -322,17 +282,12 @@ $("copy-url").addEventListener("click", event => busy(event.currentTarget, async $("open-form").addEventListener("submit", event => { event.preventDefault(); - busy(event.submitter, async () => { + try { 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); - } - }); + location.assign(sharePath(ref.id, ref.url)); + } catch (error) { + notify(error.message, true); + } }); for (const link of document.querySelectorAll("[data-panel]")) { @@ -344,7 +299,6 @@ for (const link of document.querySelectorAll("[data-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))); diff --git a/static/paste.js b/static/paste.js new file mode 100644 index 0000000..3e1f126 --- /dev/null +++ b/static/paste.js @@ -0,0 +1,22 @@ +"use strict"; + +const copy = document.getElementById("copy-code"); +const status = document.getElementById("copy-status"); +const fallback = document.getElementById("raw-code"); + +copy.addEventListener("click", async () => { + const bytes = Uint8Array.from(atob(copy.dataset.raw), character => character.charCodeAt(0)); + const content = new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes); + copy.disabled = true; + try { + await navigator.clipboard.writeText(content); + fallback.hidden = true; + } catch { + fallback.value = content; + fallback.hidden = false; + fallback.focus(); + fallback.select(); + } finally { + copy.disabled = false; + } +}); diff --git a/templates/gui.html b/templates/gui.html index 9884f95..27c6571 100644 --- a/templates/gui.html +++ b/templates/gui.html @@ -89,15 +89,6 @@

If a custom ID is taken, you'll get a fresh one. Always use the link returned after saving.

- + +

READY TO SHARE

+

Your share is ready

+

+ +
+ + +
+
+ Open share ↗ +
+
+

Opening counts as a view. Copying the link does not.

+
{% endblock %} diff --git a/templates/note.html b/templates/note.html index 8ec8210..a1233b2 100644 --- a/templates/note.html +++ b/templates/note.html @@ -4,8 +4,14 @@

SHARED PASTE

{{ paste.id }}

-

Views: {{ paste.views }} / {% match paste.max_views %}{% when Some with (max) %}{{ max }}{% when None %}∞{% endmatch %}

+
+

Views: {{ paste.views }} / {% match paste.max_views %}{% when Some with (max) %}{{ max }}{% when None %}∞{% endmatch %}

+ +
{{ code|safe }} + +
+ {% endblock %}