From b444b5759d6aa46e7e2221e1a3a05e0968eb3269 Mon Sep 17 00:00:00 2001 From: maurice Date: Sat, 14 Nov 2026 18:20:29 +0100 Subject: [PATCH] More gui --- Cargo.lock | 13 ++ Cargo.toml | 2 + README.md | 19 +- src/main.rs | 8 +- src/paste.rs | 4 +- src/routes.rs | 2 + src/routes/create.rs | 27 ++- src/routes/get_note.rs | 18 +- src/routes/gui.rs | 17 ++ src/routes/home.rs | 44 ++++- src/routes/list_notes.rs | 12 +- static/app.css | 388 ++++++++++++++++++++++++++++++++++++++- static/gui.js | 353 +++++++++++++++++++++++++++++++++++ templates/base.html | 15 +- templates/gui.html | 134 ++++++++++++++ templates/home.html | 7 +- templates/note.html | 11 +- 17 files changed, 1043 insertions(+), 31 deletions(-) create mode 100644 src/routes/gui.rs create mode 100644 static/gui.js create mode 100644 templates/gui.html diff --git a/Cargo.lock b/Cargo.lock index 8fa366a..0b467a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1035,6 +1035,8 @@ dependencies = [ "axum", "base64 0.23.1", "irosashi", + "pulldown-cmark", + "rand", "serde", "sqlx", "tokio", @@ -1088,6 +1090,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + [[package]] name = "quote" version = "1.0.47" diff --git a/Cargo.toml b/Cargo.toml index 44b69f3..ad573a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,8 @@ askama_web = { version = "0.16.0", features = ["axum-0.8"] } axum = "0.8.9" base64 = "0.23.1" irosashi = "0.3.1" +pulldown-cmark = { version = "0.9.6", default-features = false } +rand = "0.10.3" serde = { version = "1.0.229", features = ["derive"] } sqlx = { version = "0.9.0", features = ["runtime-tokio", "sqlite"] } tokio = { version = "1.54.1", features = ["rt-multi-thread"] } diff --git a/README.md b/README.md index 43030f5..c5c4109 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ cargo run ``` The server listens on `0.0.0.0:3000` and is available locally at `http://localhost:3000`. +Set `PORT` to listen on another port. On startup, the server creates the SQLite database and initializes its tables before accepting requests. The default database is `pastable.db` in the working directory. Set `DATABASE_URL` to use a different location: @@ -21,6 +22,16 @@ DATABASE_URL=sqlite://data.db cargo run Route handlers can access the shared database pool with the `State(pool): State` extractor. +## 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. + +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. + +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. + ## Features - Share text and code, with optional syntax highlighting. @@ -47,7 +58,7 @@ All paths are relative to the server URL. `{id}` is a path parameter containing | Form parameter | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `content` | String | Yes | — | Text or code to store. For raw text requests, the entire body supplies this value. | -| `language` | String | No | Not set | Language for syntax highlighting. | +| `language` | String | No | Auto detect | Language for syntax highlighting. When omitted, irosashi detects from the first line (for example a shebang), then from a requested custom ID such as `main.rs`. Without a match, the paste stays plain text. Use `text` to force plain text. | | `expires` | Integer | No | No expiration | Lifetime in seconds from the request; `0` expires immediately. Must be nonnegative. | | `max_views` | Integer | No | Unlimited views | Positive maximum number of views before self-destruction. The `/once` routes force this to `1`. | @@ -55,7 +66,7 @@ All paths are relative to the server URL. `{id}` is a path parameter containing **Returns:** the assigned ID as plain text. This is an ID, not a full URL. Always use the returned ID, since a requested custom ID may already be taken. -IDs are shared by pastes and short links. Reserved route names (`list`, `once`, `to`, and `static`) and IDs containing `/` are unavailable as custom IDs; these also receive a generated ID. +Generated IDs contain exactly 8 alphanumeric characters, generated in Rust with `rand`. Confusing characters (`0`, `1`, `I`, `L`, `O`, `i`, `l`, `o`) are excluded. IDs are shared by pastes and short links. Reserved route names (`gui`, `list`, `once`, `to`, and `static`) and IDs containing `/` are unavailable as custom IDs; these also receive a generated ID. Create a paste from raw text: @@ -93,6 +104,8 @@ 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. + 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. ```sh @@ -175,6 +188,8 @@ curl --request PUT --user 'alice:example-password' 'http://localhost:3000/to/my- With `Accept: text/html`, the response is an HTML table containing IDs, types, content, languages, view counts and limits, creation and update times, and expiration times. Dates are displayed in the browser's local timezone. +With `Accept: application/json`, the response is an array of full `Paste` records, including `id`, `paste_type` (`TextV1` or `UrlV1`), `content`, `language`, `expiration`, `max_views`, `views`, `created_at`, `updated_at`, and `owner`. Timestamps are Unix seconds; unset optional values are `null`. + ```sh curl --user 'alice:example-password' 'http://localhost:3000/list' ``` diff --git a/src/main.rs b/src/main.rs index 100bdc0..f1fb957 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,7 @@ mod db; mod paste; mod routes; use crate::routes::{ - delete_note, get_note, get_redirect, home_page, list_notes, post_note, post_once, + delete_note, get_note, get_redirect, gui_page, home_page, list_notes, post_note, post_once, post_redirect, put_note, put_redirect, }; @@ -24,6 +24,7 @@ async fn main() -> Result<(), Box> { let app = Router::new() .route("/", get(home_page).post(post_note)) + .route("/gui", get(gui_page)) .route("/once", post(post_once)) .route("/once/{id}", post(post_once)) .route("/to", post(post_redirect)) @@ -46,8 +47,9 @@ async fn main() -> Result<(), Box> { )) .with_state(pool); - let listener = TcpListener::bind("0.0.0.0:3000").await?; - println!("Listening on http://localhost:3000"); + let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_owned()); + let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await?; + println!("Listening on http://localhost:{port}"); serve(listener, app).await?; Ok(()) } diff --git a/src/paste.rs b/src/paste.rs index 8e3a2fd..89b74c9 100644 --- a/src/paste.rs +++ b/src/paste.rs @@ -1,6 +1,6 @@ use sqlx::SqlitePool; -#[derive(sqlx::FromRow)] +#[derive(serde::Serialize, sqlx::FromRow)] pub struct Paste { pub id: String, #[sqlx(rename = "type")] @@ -15,7 +15,7 @@ pub struct Paste { pub owner: Option, } -#[derive(sqlx::Type)] +#[derive(serde::Serialize, sqlx::Type)] #[repr(i64)] pub enum PasteType { TextV1 = 0, diff --git a/src/routes.rs b/src/routes.rs index e7e544f..50becd6 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -4,6 +4,7 @@ mod create; mod delete_note; mod get_note; mod get_redirect; +mod gui; mod home; mod input; mod list_notes; @@ -18,6 +19,7 @@ mod update; pub use delete_note::delete_note; pub use get_note::get_note; pub use get_redirect::get_redirect; +pub use gui::gui_page; pub use home::home_page; pub use list_notes::list_notes; pub use post_note::post_note; diff --git a/src/routes/create.rs b/src/routes/create.rs index 440677d..c16c583 100644 --- a/src/routes/create.rs +++ b/src/routes/create.rs @@ -1,4 +1,5 @@ use axum::{Extension, extract::Path, http::StatusCode}; +use irosashi::Highlighter; use sqlx::SqlitePool; use crate::{auth::User, paste::PasteType}; @@ -9,21 +10,30 @@ pub async fn create( db: &SqlitePool, id: Option>, user: Option>, - input: PasteInput, + mut input: PasteInput, paste_type: PasteType, ) -> Result { let mut id = id.map(|Path(id)| id).filter(|id| { - !matches!(id.as_str(), "list" | "once" | "to" | "static") && !id.contains('/') + !matches!(id.as_str(), "gui" | "list" | "once" | "to" | "static") && !id.contains('/') }); + + if matches!(paste_type, PasteType::TextV1) && input.language.is_none() { + let highlighter = Highlighter::new().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + input.language = highlighter + .detect_language_by_first_line(input.content.lines().next().unwrap_or_default()); + } + let owner = user.map(|Extension(user)| user.username); let expiration = input.expiration()?; + loop { + let candidate = id.take().unwrap_or_else(generate_id); let created = sqlx::query_scalar( "INSERT INTO notes (id, type, content, language, expiration, max_views, created_at, owner) - VALUES (COALESCE(?, lower(hex(randomblob(12)))), ?, ?, ?, ?, ?, unixepoch(), ?) + VALUES (?, ?, ?, ?, ?, ?, unixepoch(), ?) ON CONFLICT(id) DO NOTHING RETURNING id", ) - .bind(id.as_deref()) + .bind(candidate) .bind(&paste_type) .bind(&input.content) .bind(&input.language) @@ -33,9 +43,16 @@ pub async fn create( .fetch_optional(db) .await .map_err(db_error)?; + if let Some(id) = created { return Ok(id); } - id = None; } } + +fn generate_id() -> String { + const ALPHABET: &[u8] = b"23456789ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz"; + (0..8) + .map(|_| ALPHABET[rand::random_range(0..ALPHABET.len())] as char) + .collect() +} diff --git a/src/routes/get_note.rs b/src/routes/get_note.rs index 13608ca..9f5d0ee 100644 --- a/src/routes/get_note.rs +++ b/src/routes/get_note.rs @@ -2,7 +2,7 @@ use askama::Template; use askama_web::WebTemplate; use axum::{ extract::{Path, State}, - http::{HeaderMap, StatusCode}, + http::{HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, }; use irosashi::{CodeToHtmlOptions, Highlighter}; @@ -15,6 +15,7 @@ use super::{db_error, return_html}; #[derive(Template, WebTemplate)] #[template(path = "note.html")] pub struct NotePage { + paste: Paste, code: String, } @@ -28,7 +29,13 @@ pub async fn get_note( .map_err(db_error)? .ok_or(StatusCode::NOT_FOUND)?; - if return_html(&headers) { + let mut metadata = HeaderMap::new(); + metadata.insert("x-paste-views", HeaderValue::from(paste.views)); + if let Some(max) = paste.max_views { + metadata.insert("x-paste-max-views", HeaderValue::from(max)); + } + + let response = if return_html(&headers) { let highlighter = Highlighter::new().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let code = highlighter .code_to_html( @@ -39,8 +46,9 @@ pub async fn get_note( ), ) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(NotePage { code }.into_response()) + NotePage { paste, code }.into_response() } else { - Ok(paste.content.into_response()) - } + paste.content.into_response() + }; + Ok((metadata, response).into_response()) } diff --git a/src/routes/gui.rs b/src/routes/gui.rs new file mode 100644 index 0000000..63fc514 --- /dev/null +++ b/src/routes/gui.rs @@ -0,0 +1,17 @@ +use askama::Template; +use askama_web::WebTemplate; +use axum::http::StatusCode; +use irosashi::Highlighter; + +#[derive(Template, WebTemplate)] +#[template(path = "gui.html")] +pub struct GuiPage { + languages: Vec, +} + +pub async fn gui_page() -> Result { + let highlighter = Highlighter::new().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(GuiPage { + languages: highlighter.languages(), + }) +} diff --git a/src/routes/home.rs b/src/routes/home.rs index b034d4c..faad15c 100644 --- a/src/routes/home.rs +++ b/src/routes/home.rs @@ -1,10 +1,50 @@ +use std::sync::LazyLock; + use askama::Template; use askama_web::WebTemplate; +use pulldown_cmark::{Event, Options, Parser, Tag, html}; + +static README_HTML: LazyLock = LazyLock::new(|| { + let events: Vec<_> = Parser::new_ext( + include_str!("../../README.md"), + Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS, + ) + .collect(); + let anchored = events.iter().enumerate().map(|(index, event)| { + if let Event::Start(Tag::Heading(level, None, _)) = event { + let title: String = events[index + 1..] + .iter() + .take_while(|event| !matches!(event, Event::End(Tag::Heading(..)))) + .filter_map(|event| match event { + Event::Text(text) | Event::Code(text) => Some(text.as_ref()), + _ => None, + }) + .collect(); + let slug: String = title + .to_lowercase() + .chars() + .filter(|character| { + character.is_alphanumeric() || *character == '-' || character.is_whitespace() + }) + .collect(); + let slug = slug.split_whitespace().collect::>().join("-"); + return Event::Html(format!("<{level} id=\"{slug}\">").into()); + } + event.clone() + }); + let mut rendered = String::new(); + html::push_html(&mut rendered, anchored); + rendered +}); #[derive(Template, WebTemplate)] #[template(path = "home.html")] -pub struct HomePage; +pub struct HomePage { + readme: &'static str, +} pub async fn home_page() -> HomePage { - HomePage + HomePage { + readme: &README_HTML, + } } diff --git a/src/routes/list_notes.rs b/src/routes/list_notes.rs index f4a7d28..544860c 100644 --- a/src/routes/list_notes.rs +++ b/src/routes/list_notes.rs @@ -1,9 +1,9 @@ use askama::Template; use askama_web::WebTemplate; use axum::{ - Extension, + Extension, Json, extract::State, - http::{HeaderMap, StatusCode}, + http::{HeaderMap, StatusCode, header::ACCEPT}, response::{IntoResponse, Response}, }; use sqlx::SqlitePool; @@ -38,7 +38,13 @@ pub async fn list_notes( .await .map_err(db_error)?; - if return_html(&headers) { + if headers + .get(ACCEPT) + .and_then(|value| value.to_str().ok()) + .is_some_and(|accept| accept.contains("application/json")) + { + Ok(Json(notes).into_response()) + } else if return_html(&headers) { Ok(ListPage { notes }.into_response()) } else { Ok(notes diff --git a/static/app.css b/static/app.css index ff9f381..424dccf 100644 --- a/static/app.css +++ b/static/app.css @@ -4,10 +4,11 @@ --muted: #60675c; --accent: #376345; --surface: #eef1e9; + --border: #d8ded2; } body { - max-width: 42rem; + max-width: 76rem; margin: 2rem auto; padding: 0 1rem; font-family: system-ui, sans-serif; @@ -16,6 +17,85 @@ body { background: var(--background); } +.site-header, .brand, .site-header nav { + display: flex; + align-items: center; +} + +.site-header { + justify-content: space-between; + gap: 1rem; + padding-bottom: 1.25rem; + border-bottom: 1px solid var(--border); +} + +.brand { + gap: 0.65rem; + font-size: 1.3rem; + font-weight: 750; + letter-spacing: -0.04em; + text-decoration: none; +} + +.site-header nav { + gap: 1.25rem; + font-size: 0.85rem; +} + +.site-header nav a { + color: var(--muted); + text-decoration: none; +} + +.site-header nav a[aria-current] { + color: var(--accent); + font-weight: 650; +} + +.readme { + max-width: 60rem; + margin-inline: auto; + overflow-wrap: anywhere; +} + +.readme h2 { + margin-top: 2.25rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); + font-size: 1.5rem; +} + +.readme h3 { + margin-top: 1.75rem; +} + +.readme h1, .readme h2, .readme h3 { + scroll-margin-top: 1rem; +} + +.readme table { + display: block; + margin-block: 1.5rem; + overflow-x: auto; +} + +.readme :not(pre) > code { + padding: 0.1rem 0.3rem; + border-radius: 0.2rem; + background: var(--surface); + font-size: 0.9em; +} + +.paste-view h1 { + font-size: 1.75rem; + overflow-wrap: anywhere; +} + +.view-counter { + display: inline-block; + flex-shrink: 0; +} + a { color: var(--accent); } @@ -44,8 +124,9 @@ table { } th, td { - padding: 0.5rem; - border-bottom: 1px solid var(--muted); + padding: 0.8rem; + border-bottom: 1px solid var(--border); + font-size: 0.8rem; text-align: left; vertical-align: top; } @@ -60,3 +141,304 @@ td pre { time { white-space: nowrap; } + +h1, h2, p { + margin-top: 0; +} + +h1 { + margin-bottom: 0.5rem; + font-size: clamp(2rem, 5vw, 3rem); + line-height: 1.15; + letter-spacing: -0.04em; +} + +h2 { + font-size: 1.15rem; + letter-spacing: -0.02em; +} + +.muted, .hint { + color: var(--muted); +} + +.hint { + font-size: 0.85rem; +} + +.eyebrow { + margin-bottom: 0.65rem; + font-size: 0.7rem; + font-weight: 750; + letter-spacing: 0.12em; + color: var(--accent); +} + +.workspace-heading, .section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.badge, .count { + padding: 0.2rem 0.65rem; + border-radius: 2rem; + background: var(--surface); + color: var(--accent); + font-size: 0.8rem; + overflow-wrap: anywhere; +} + +.card { + padding: clamp(1rem, 3vw, 1.75rem); + border: 1px solid var(--border); + border-radius: 0.75rem; + background: #fff; +} + +.account { + margin: 1rem 0 1.5rem; +} + +.account summary { + cursor: pointer; + font-weight: 650; +} + +.account form { + margin-top: 1rem; +} + +.workspace-tabs { + display: flex; + gap: 1.5rem; + margin: 1.75rem 0; + border-bottom: 1px solid var(--border); + overflow-x: auto; +} + +.workspace-tabs a { + padding: 0.75rem 0; + border-bottom: 2px solid transparent; + color: var(--muted); + text-decoration: none; + font-size: 0.9rem; + font-weight: 650; + white-space: nowrap; +} + +.workspace-tabs a[aria-current] { + border-bottom-color: var(--accent); + color: var(--accent); +} + +.editor-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 17rem; + align-items: start; + gap: 1.25rem; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +label { + display: block; + margin-bottom: 1rem; + font-size: 0.85rem; + font-weight: 600; +} + +input, select, textarea, button { + box-sizing: border-box; + font: inherit; +} + +input:not([type="checkbox"]), select, textarea { + display: block; + width: 100%; + margin-top: 0.4rem; + padding: 0.7rem 0.8rem; + border: 1px solid var(--border); + border-radius: 0.4rem; + color: var(--text); + background: var(--background); + font-weight: 400; +} + +textarea { + resize: vertical; + font-family: ui-monospace, monospace; + font-size: 0.875rem; + line-height: 1.6; + tab-size: 4; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +button { + padding: 0.65rem 1rem; + border: 1px solid transparent; + border-radius: 0.4rem; + background: var(--accent); + color: white; + font-size: 0.85rem; + font-weight: 650; + cursor: pointer; +} + +button:hover { + filter: brightness(0.94); +} + +.secondary { + border-color: var(--border); + background: white; + color: var(--text); +} + +.danger { + border-color: #ead5d1; + background: #fff8f6; + 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; +} + +input:disabled, select:disabled { + opacity: 0.65; +} + +.actions, .copy-row { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.editor-actions { + margin-top: 1.5rem; +} + +.copy-row input { + flex: 1; + min-width: 12rem; + margin-top: 0 !important; +} + +.check-label { + display: flex; + align-items: center; + gap: 0.5rem; +} + +input[type="checkbox"] { + accent-color: var(--accent); +} + +hr { + margin: 1.5rem 0; + border: 0; + border-top: 1px solid var(--border); +} + +.status { + padding: 0.9rem 1rem; + border: 1px solid #cadacb; + border-radius: 0.5rem; + background: #edf5ee; + color: #254e30; + overflow-wrap: anywhere; +} + +.status[data-error="true"] { + border-color: #e8cfc8; + background: #fff1ec; + color: #7f3022; +} + +.result, .preview { + margin-top: 1.25rem; +} + +.result p:last-child { + margin-bottom: 0; +} + +.search-label { + max-width: 26rem; +} + +.empty-state { + padding: 3rem 1rem; + color: var(--muted); + text-align: center; +} + +td small { + display: block; + margin-top: 0.3rem; + color: var(--muted); +} + +.content-preview { + min-width: 10rem; + max-width: 18rem; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-family: ui-monospace, monospace; +} + +.row-actions { + min-width: 9rem; +} + +.row-actions button { + margin: 0 0.4rem 0.4rem 0; + padding: 0.35rem 0.6rem; +} + +[hidden] { + display: none !important; +} + +@media (max-width: 760px) { + .editor-layout, .form-grid { + grid-template-columns: 1fr; + } + + .workspace-heading { + flex-wrap: wrap; + } + + .site-header { + flex-wrap: wrap; + } + + .workspace-tabs { + gap: 1.1rem; + } + + body { + margin-top: 1rem; + } +} diff --git a/static/gui.js b/static/gui.js new file mode 100644 index 0000000..e3b658d --- /dev/null +++ b/static/gui.js @@ -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(); diff --git a/templates/base.html b/templates/base.html index ae3e09a..c57728d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -6,10 +6,21 @@ {% block title %}Pastable{% endblock %} + {% block head %}{% endblock %} -
- Pastable +
{% block content %} diff --git a/templates/gui.html b/templates/gui.html new file mode 100644 index 0000000..9884f95 --- /dev/null +++ b/templates/gui.html @@ -0,0 +1,134 @@ +{% extends "base.html" %} +{% block title %}Browser app · Pastable{% endblock %} +{% block gui_nav %}aria-current="page"{% endblock %} +{% block content %} +
+
+

YOUR SHARING WORKSPACE

+

Pastes, links & secrets.

+

A little space for things you want to pass along.

+
+ Anonymous +
+ + + + + + + +
+
+
+
+

Create a share

+ +
+
+
+ + +
+ +
+ +
+ +
+ + Shared anonymously. +
+
+
+ +
+ +
+ + + + + + +{% endblock %} diff --git a/templates/home.html b/templates/home.html index b497ea2..9c134fd 100644 --- a/templates/home.html +++ b/templates/home.html @@ -1,4 +1,7 @@ -{% extends "base.html" %} +{% extends "base.html" %} +{% block home_nav %}aria-current="page"{% endblock %} {% block content %} -

Pastable

+
+ {{ readme|safe }} +
{% endblock %} diff --git a/templates/note.html b/templates/note.html index 248ee5b..8ec8210 100644 --- a/templates/note.html +++ b/templates/note.html @@ -1,4 +1,11 @@ -{% extends "base.html" %} +{% extends "base.html" %} +{% block title %}{{ paste.id }} · Pastable{% endblock %} {% block content %} -{{ code|safe }} +
+
+

SHARED PASTE

{{ paste.id }}

+

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

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