More gui
This commit is contained in:
17 files changed
+1040
-28
No files matched your search
Generated
+13
@@ -1035,6 +1035,8 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"irosashi",
|
"irosashi",
|
||||||
|
"pulldown-cmark",
|
||||||
|
"rand",
|
||||||
"serde",
|
"serde",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -1088,6 +1090,17 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ askama_web = { version = "0.16.0", features = ["axum-0.8"] }
|
|||||||
axum = "0.8.9"
|
axum = "0.8.9"
|
||||||
base64 = "0.23.1"
|
base64 = "0.23.1"
|
||||||
irosashi = "0.3.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"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
sqlx = { version = "0.9.0", features = ["runtime-tokio", "sqlite"] }
|
sqlx = { version = "0.9.0", features = ["runtime-tokio", "sqlite"] }
|
||||||
tokio = { version = "1.54.1", features = ["rt-multi-thread"] }
|
tokio = { version = "1.54.1", features = ["rt-multi-thread"] }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ cargo run
|
|||||||
```
|
```
|
||||||
|
|
||||||
The server listens on `0.0.0.0:3000` and is available locally at `http://localhost:3000`.
|
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.
|
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:
|
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<SqlitePool>` extractor.
|
Route handlers can access the shared database pool with the `State(pool): State<SqlitePool>` 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
|
## Features
|
||||||
|
|
||||||
- Share text and code, with optional syntax highlighting.
|
- 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 |
|
| Form parameter | Type | Required | Default | Description |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| `content` | String | Yes | — | Text or code to store. For raw text requests, the entire body supplies this value. |
|
| `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. |
|
| `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`. |
|
| `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.
|
**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:
|
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.
|
**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.
|
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
|
```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: 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
|
```sh
|
||||||
curl --user 'alice:example-password' 'http://localhost:3000/list'
|
curl --user 'alice:example-password' 'http://localhost:3000/list'
|
||||||
```
|
```
|
||||||
|
|||||||
+5
-3
@@ -11,7 +11,7 @@ mod db;
|
|||||||
mod paste;
|
mod paste;
|
||||||
mod routes;
|
mod routes;
|
||||||
use crate::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,
|
post_redirect, put_note, put_redirect,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(home_page).post(post_note))
|
.route("/", get(home_page).post(post_note))
|
||||||
|
.route("/gui", get(gui_page))
|
||||||
.route("/once", post(post_once))
|
.route("/once", post(post_once))
|
||||||
.route("/once/{id}", post(post_once))
|
.route("/once/{id}", post(post_once))
|
||||||
.route("/to", post(post_redirect))
|
.route("/to", post(post_redirect))
|
||||||
@@ -46,8 +47,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
))
|
))
|
||||||
.with_state(pool);
|
.with_state(pool);
|
||||||
|
|
||||||
let listener = TcpListener::bind("0.0.0.0:3000").await?;
|
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_owned());
|
||||||
println!("Listening on http://localhost:3000");
|
let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await?;
|
||||||
|
println!("Listening on http://localhost:{port}");
|
||||||
serve(listener, app).await?;
|
serve(listener, app).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(serde::Serialize, sqlx::FromRow)]
|
||||||
pub struct Paste {
|
pub struct Paste {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
#[sqlx(rename = "type")]
|
#[sqlx(rename = "type")]
|
||||||
@@ -15,7 +15,7 @@ pub struct Paste {
|
|||||||
pub owner: Option<String>,
|
pub owner: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::Type)]
|
#[derive(serde::Serialize, sqlx::Type)]
|
||||||
#[repr(i64)]
|
#[repr(i64)]
|
||||||
pub enum PasteType {
|
pub enum PasteType {
|
||||||
TextV1 = 0,
|
TextV1 = 0,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mod create;
|
|||||||
mod delete_note;
|
mod delete_note;
|
||||||
mod get_note;
|
mod get_note;
|
||||||
mod get_redirect;
|
mod get_redirect;
|
||||||
|
mod gui;
|
||||||
mod home;
|
mod home;
|
||||||
mod input;
|
mod input;
|
||||||
mod list_notes;
|
mod list_notes;
|
||||||
@@ -18,6 +19,7 @@ mod update;
|
|||||||
pub use delete_note::delete_note;
|
pub use delete_note::delete_note;
|
||||||
pub use get_note::get_note;
|
pub use get_note::get_note;
|
||||||
pub use get_redirect::get_redirect;
|
pub use get_redirect::get_redirect;
|
||||||
|
pub use gui::gui_page;
|
||||||
pub use home::home_page;
|
pub use home::home_page;
|
||||||
pub use list_notes::list_notes;
|
pub use list_notes::list_notes;
|
||||||
pub use post_note::post_note;
|
pub use post_note::post_note;
|
||||||
|
|||||||
+22
-5
@@ -1,4 +1,5 @@
|
|||||||
use axum::{Extension, extract::Path, http::StatusCode};
|
use axum::{Extension, extract::Path, http::StatusCode};
|
||||||
|
use irosashi::Highlighter;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use crate::{auth::User, paste::PasteType};
|
use crate::{auth::User, paste::PasteType};
|
||||||
@@ -9,21 +10,30 @@ pub async fn create(
|
|||||||
db: &SqlitePool,
|
db: &SqlitePool,
|
||||||
id: Option<Path<String>>,
|
id: Option<Path<String>>,
|
||||||
user: Option<Extension<User>>,
|
user: Option<Extension<User>>,
|
||||||
input: PasteInput,
|
mut input: PasteInput,
|
||||||
paste_type: PasteType,
|
paste_type: PasteType,
|
||||||
) -> Result<String, StatusCode> {
|
) -> Result<String, StatusCode> {
|
||||||
let mut id = id.map(|Path(id)| id).filter(|id| {
|
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 owner = user.map(|Extension(user)| user.username);
|
||||||
let expiration = input.expiration()?;
|
let expiration = input.expiration()?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
let candidate = id.take().unwrap_or_else(generate_id);
|
||||||
let created = sqlx::query_scalar(
|
let created = sqlx::query_scalar(
|
||||||
"INSERT INTO notes (id, type, content, language, expiration, max_views, created_at, owner)
|
"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",
|
ON CONFLICT(id) DO NOTHING RETURNING id",
|
||||||
)
|
)
|
||||||
.bind(id.as_deref())
|
.bind(candidate)
|
||||||
.bind(&paste_type)
|
.bind(&paste_type)
|
||||||
.bind(&input.content)
|
.bind(&input.content)
|
||||||
.bind(&input.language)
|
.bind(&input.language)
|
||||||
@@ -33,9 +43,16 @@ pub async fn create(
|
|||||||
.fetch_optional(db)
|
.fetch_optional(db)
|
||||||
.await
|
.await
|
||||||
.map_err(db_error)?;
|
.map_err(db_error)?;
|
||||||
|
|
||||||
if let Some(id) = created {
|
if let Some(id) = created {
|
||||||
return Ok(id);
|
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()
|
||||||
|
}
|
||||||
+13
-5
@@ -2,7 +2,7 @@ use askama::Template;
|
|||||||
use askama_web::WebTemplate;
|
use askama_web::WebTemplate;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, HeaderValue, StatusCode},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use irosashi::{CodeToHtmlOptions, Highlighter};
|
use irosashi::{CodeToHtmlOptions, Highlighter};
|
||||||
@@ -15,6 +15,7 @@ use super::{db_error, return_html};
|
|||||||
#[derive(Template, WebTemplate)]
|
#[derive(Template, WebTemplate)]
|
||||||
#[template(path = "note.html")]
|
#[template(path = "note.html")]
|
||||||
pub struct NotePage {
|
pub struct NotePage {
|
||||||
|
paste: Paste,
|
||||||
code: String,
|
code: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +29,13 @@ pub async fn get_note(
|
|||||||
.map_err(db_error)?
|
.map_err(db_error)?
|
||||||
.ok_or(StatusCode::NOT_FOUND)?;
|
.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 highlighter = Highlighter::new().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
let code = highlighter
|
let code = highlighter
|
||||||
.code_to_html(
|
.code_to_html(
|
||||||
@@ -39,8 +46,9 @@ pub async fn get_note(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
Ok(NotePage { code }.into_response())
|
NotePage { paste, code }.into_response()
|
||||||
} else {
|
} else {
|
||||||
Ok(paste.content.into_response())
|
paste.content.into_response()
|
||||||
}
|
};
|
||||||
|
Ok((metadata, response).into_response())
|
||||||
}
|
}
|
||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn gui_page() -> Result<GuiPage, StatusCode> {
|
||||||
|
let highlighter = Highlighter::new().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
Ok(GuiPage {
|
||||||
|
languages: highlighter.languages(),
|
||||||
|
})
|
||||||
|
}
|
||||||
+42
-2
@@ -1,10 +1,50 @@
|
|||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use askama_web::WebTemplate;
|
use askama_web::WebTemplate;
|
||||||
|
use pulldown_cmark::{Event, Options, Parser, Tag, html};
|
||||||
|
|
||||||
|
static README_HTML: LazyLock<String> = 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::<Vec<_>>().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)]
|
#[derive(Template, WebTemplate)]
|
||||||
#[template(path = "home.html")]
|
#[template(path = "home.html")]
|
||||||
pub struct HomePage;
|
pub struct HomePage {
|
||||||
|
readme: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn home_page() -> HomePage {
|
pub async fn home_page() -> HomePage {
|
||||||
HomePage
|
HomePage {
|
||||||
|
readme: &README_HTML,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
use askama::Template;
|
use askama::Template;
|
||||||
use askama_web::WebTemplate;
|
use askama_web::WebTemplate;
|
||||||
use axum::{
|
use axum::{
|
||||||
Extension,
|
Extension, Json,
|
||||||
extract::State,
|
extract::State,
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode, header::ACCEPT},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
@@ -38,7 +38,13 @@ pub async fn list_notes(
|
|||||||
.await
|
.await
|
||||||
.map_err(db_error)?;
|
.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())
|
Ok(ListPage { notes }.into_response())
|
||||||
} else {
|
} else {
|
||||||
Ok(notes
|
Ok(notes
|
||||||
|
|||||||
+385
-3
@@ -4,10 +4,11 @@
|
|||||||
--muted: #60675c;
|
--muted: #60675c;
|
||||||
--accent: #376345;
|
--accent: #376345;
|
||||||
--surface: #eef1e9;
|
--surface: #eef1e9;
|
||||||
|
--border: #d8ded2;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
max-width: 42rem;
|
max-width: 76rem;
|
||||||
margin: 2rem auto;
|
margin: 2rem auto;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
font-family: system-ui, sans-serif;
|
font-family: system-ui, sans-serif;
|
||||||
@@ -16,6 +17,85 @@ body {
|
|||||||
background: var(--background);
|
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 {
|
a {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
@@ -44,8 +124,9 @@ table {
|
|||||||
}
|
}
|
||||||
|
|
||||||
th, td {
|
th, td {
|
||||||
padding: 0.5rem;
|
padding: 0.8rem;
|
||||||
border-bottom: 1px solid var(--muted);
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.8rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
@@ -60,3 +141,304 @@ td pre {
|
|||||||
time {
|
time {
|
||||||
white-space: nowrap;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+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();
|
||||||
+13
-2
@@ -6,10 +6,21 @@
|
|||||||
<meta name="description" content="Pastable">
|
<meta name="description" content="Pastable">
|
||||||
<title>{% block title %}Pastable{% endblock %}</title>
|
<title>{% block title %}Pastable{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header class="site-header">
|
||||||
<a href="/">Pastable</a>
|
<a class="brand" href="/" aria-label="Pastable home">
|
||||||
|
<svg width="30" height="34" viewBox="0 0 30 34" fill="none" aria-hidden="true">
|
||||||
|
<rect x="2" y="2" width="26" height="30" rx="6" fill="currentColor"/>
|
||||||
|
<path d="M9 11h12M9 17h12M9 23h7" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>Pastable</span>
|
||||||
|
</a>
|
||||||
|
<nav aria-label="Main navigation">
|
||||||
|
<a href="/" {% block home_nav %}{% endblock %}>Documentation</a>
|
||||||
|
<a href="/gui" {% block gui_nav %}{% endblock %}>Browser app <span aria-hidden="true">↗</span></a>
|
||||||
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Browser app · Pastable{% endblock %}
|
||||||
|
{% block gui_nav %}aria-current="page"{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="workspace-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">YOUR SHARING WORKSPACE</p>
|
||||||
|
<h1>Pastes, links & secrets.</h1>
|
||||||
|
<p class="muted">A little space for things you want to pass along.</p>
|
||||||
|
</div>
|
||||||
|
<span id="session-label" class="badge">Anonymous</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details id="account" class="card account">
|
||||||
|
<summary>Account <span class="muted">· optional</span></summary>
|
||||||
|
<form id="login-form">
|
||||||
|
<p class="muted">Sign in to manage your shares. A new username creates an account.</p>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>Username<input id="username" autocomplete="username" required pattern="[^:]+" maxlength="128"></label>
|
||||||
|
<label>Password<input id="password" type="password" autocomplete="current-password" required></label>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Sign in / create account</button>
|
||||||
|
<button id="logout" type="button" class="secondary" hidden>Sign out</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Your credentials stay in memory for this page only.</p>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<p id="status" class="status" role="status" aria-live="polite" tabindex="-1" hidden></p>
|
||||||
|
|
||||||
|
<nav class="workspace-tabs" aria-label="Workspace">
|
||||||
|
<a href="#compose" data-panel="compose" aria-current="page">Create a share</a>
|
||||||
|
<a href="#collection" data-panel="collection">My collection <span id="note-count" class="count">0</span></a>
|
||||||
|
<a href="#open" data-panel="open">Open a share</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section id="compose" class="panel" aria-labelledby="editor-title">
|
||||||
|
<div class="editor-layout">
|
||||||
|
<div class="card">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2 id="editor-title">Create a share</h2>
|
||||||
|
<button id="new-note" type="button" class="secondary" hidden>Cancel editing</button>
|
||||||
|
</div>
|
||||||
|
<form id="editor-form">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>What are you sharing?
|
||||||
|
<select id="kind"><option value="note">Text or code</option><option value="url">Short link</option></select>
|
||||||
|
</label>
|
||||||
|
<label>Custom ID <span class="muted">· optional</span>
|
||||||
|
<input id="custom-id" placeholder="Generate one for me" pattern="[^/]+" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label><span id="content-label">Your text or code</span>
|
||||||
|
<textarea id="content" rows="15" spellcheck="false" placeholder="Paste something worth sharing…" required></textarea>
|
||||||
|
</label>
|
||||||
|
<div id="language-field">
|
||||||
|
<label>Language <span class="muted">· optional</span>
|
||||||
|
<select id="language">
|
||||||
|
<option value="">Auto detect</option>
|
||||||
|
<option value="text">Plain text</option>
|
||||||
|
{% for language in languages %}
|
||||||
|
<option value="{{ language }}">{{ language }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p id="edit-hint" class="hint" hidden></p>
|
||||||
|
<div class="actions editor-actions">
|
||||||
|
<button id="save" type="submit">Create share</button>
|
||||||
|
<span id="ownership-hint" class="hint">Shared anonymously.</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<aside class="card options" aria-labelledby="options-title">
|
||||||
|
<p class="eyebrow">MAKE IT YOURS</p>
|
||||||
|
<h2 id="options-title">Share settings</h2>
|
||||||
|
<label>Expires after <span class="muted">· seconds</span>
|
||||||
|
<input id="expires" form="editor-form" type="number" min="0" step="1" placeholder="Never">
|
||||||
|
</label>
|
||||||
|
<label>Maximum views
|
||||||
|
<input id="max-views" form="editor-form" type="number" min="1" step="1" placeholder="Unlimited">
|
||||||
|
</label>
|
||||||
|
<label id="once-field" class="check-label">
|
||||||
|
<input id="once" form="editor-form" type="checkbox"> One-time secret
|
||||||
|
</label>
|
||||||
|
<p class="hint">One-time shares disappear after their first read. A link's views count its redirects.</p>
|
||||||
|
<hr>
|
||||||
|
<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>
|
||||||
|
<div class="section-heading">
|
||||||
|
<div><h2 id="collection-title">My collection</h2><p class="hint">Editing here does not count as a view.</p></div>
|
||||||
|
<button id="refresh" type="button" class="secondary">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<label class="search-label">Filter shares<input id="search" type="search" placeholder="Find by ID, content or language…"></label>
|
||||||
|
<div id="collection-empty" class="empty-state">Sign in to see and manage your shares.</div>
|
||||||
|
<div id="collection-table" class="table-scroll" hidden>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th scope="col">Share</th><th scope="col">Content</th><th scope="col">Views</th><th scope="col">Created</th><th scope="col">Updated</th><th scope="col">Expires</th><th scope="col">Actions</th></tr></thead>
|
||||||
|
<tbody id="note-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="open" class="panel card" aria-labelledby="open-title" hidden>
|
||||||
|
<h2 id="open-title">Open a share</h2>
|
||||||
|
<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>
|
||||||
|
</form>
|
||||||
|
<div id="preview" class="preview" aria-live="polite" hidden></div>
|
||||||
|
</section>
|
||||||
|
<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
-1
@@ -1,4 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% block home_nav %}aria-current="page"{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Pastable</h1>
|
<article class="card readme">
|
||||||
|
{{ readme|safe }}
|
||||||
|
</article>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ paste.id }} · Pastable{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<article class="card paste-view">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div><p class="eyebrow">SHARED PASTE</p><h1>{{ paste.id }}</h1></div>
|
||||||
|
<p class="badge view-counter">Views: {{ paste.views }} / {% match paste.max_views %}{% when Some with (max) %}{{ max }}{% when None %}∞{% endmatch %}</p>
|
||||||
|
</div>
|
||||||
{{ code|safe }}
|
{{ code|safe }}
|
||||||
|
</article>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in new issue
Block a user