Fixes
This commit is contained in:
7 files changed
+119
-106
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
+14
-3
@@ -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<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, StatusCode> {
|
||||
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())
|
||||
}
|
||||
+27
-11
@@ -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 {
|
||||
|
||||
+28
-74
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
+17
-15
@@ -89,15 +89,6 @@
|
||||
<p class="hint">If a custom ID is taken, you'll get a fresh one. Always use the link returned after saving.</p>
|
||||
</aside>
|
||||
</div>
|
||||
<section id="share-result" class="card result" aria-labelledby="result-title" hidden>
|
||||
<p class="eyebrow">READY TO SHARE</p>
|
||||
<h2 id="result-title">Your link</h2>
|
||||
<div class="copy-row">
|
||||
<input id="share-url" aria-label="Share URL" readonly>
|
||||
<button id="copy-url" type="button" class="secondary">Copy link</button>
|
||||
</div>
|
||||
<p><a id="share-link" target="_blank" rel="noopener noreferrer">Open share ↗</a> <span class="hint">Opening counts as a view.</span></p>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section id="collection" class="panel card" aria-labelledby="collection-title" hidden>
|
||||
@@ -120,15 +111,26 @@
|
||||
<p class="muted">Have an ID or a Pastable link? Open it here.</p>
|
||||
<form id="open-form">
|
||||
<label>ID or share URL<input id="open-id" placeholder="my-paste or a full share URL" required autocomplete="off"></label>
|
||||
<div class="form-grid">
|
||||
<label>Type<select id="open-kind"><option value="note">Text or code</option><option value="url">Short link</option></select></label>
|
||||
<label id="format-field">Display<select id="open-format"><option value="html">Syntax highlighted</option><option value="plain">Plain text</option></select></label>
|
||||
</div>
|
||||
<button type="submit" id="open-submit">Open paste</button>
|
||||
<p class="hint">Opening a paste or following a link counts as a view. Short links open in a new tab.</p>
|
||||
<button type="submit">Open share</button>
|
||||
<p class="hint">Opening a share navigates to it and counts as a view.</p>
|
||||
</form>
|
||||
<div id="preview" class="preview" aria-live="polite" hidden></div>
|
||||
</section>
|
||||
<dialog id="share-result" class="card result" aria-labelledby="result-title" aria-describedby="result-feedback">
|
||||
<p class="eyebrow">READY TO SHARE</p>
|
||||
<h2 id="result-title">Your share is ready</h2>
|
||||
<p id="result-feedback" class="hint" role="status" aria-live="polite"></p>
|
||||
<label for="share-url">Share link</label>
|
||||
<div class="copy-row">
|
||||
<input id="share-url" readonly autofocus>
|
||||
<button id="copy-url" type="button">Copy link</button>
|
||||
</div>
|
||||
<div class="actions result-actions">
|
||||
<a id="share-link">Open share ↗</a>
|
||||
<form method="dialog"><button class="secondary">Done</button></form>
|
||||
</div>
|
||||
<p class="hint">Opening counts as a view. Copying the link does not.</p>
|
||||
</dialog>
|
||||
<noscript><p class="status">Enable JavaScript to use the browser app. The HTTP API remains available.</p></noscript>
|
||||
<script src="/static/gui.js" defer></script>
|
||||
{% endblock %}
|
||||
@@ -4,8 +4,14 @@
|
||||
<article class="card paste-view">
|
||||
<div class="section-heading">
|
||||
<div><p class="eyebrow">SHARED PASTE</p><h1>{{ paste.id }}</h1></div>
|
||||
<div class="actions">
|
||||
<p class="badge view-counter">Views: {{ paste.views }} / {% match paste.max_views %}{% when Some with (max) %}{{ max }}{% when None %}∞{% endmatch %}</p>
|
||||
<button id="copy-code" class="secondary" type="button" data-raw="{{ raw_code }}">Copy raw code</button>
|
||||
</div>
|
||||
</div>
|
||||
{{ code|safe }}
|
||||
<p id="copy-status" class="hint" role="status" aria-live="polite" hidden></p>
|
||||
<textarea id="raw-code" aria-label="Raw paste content" rows="8" readonly spellcheck="false" hidden></textarea>
|
||||
</article>
|
||||
<script src="/static/paste.js" defer></script>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user