new functions and js v=

This commit is contained in:
Mateusz Gruszczyński
2026-07-25 17:15:54 +02:00
parent d1d8bb0259
commit 0a02a0880e
31 changed files with 1035 additions and 610 deletions
Generated
+1 -1
View File
@@ -2433,7 +2433,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.0.19"
version = "0.0.20"
dependencies = [
"argon2",
"aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rustpad"
version = "0.0.19"
version = "0.0.20"
edition = "2024"
rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
@@ -0,0 +1 @@
ALTER TABLE resource_share_links ADD COLUMN token TEXT NULL;
@@ -0,0 +1 @@
ALTER TABLE resource_share_links ADD COLUMN token TEXT;
@@ -0,0 +1 @@
ALTER TABLE resource_share_links ADD COLUMN token TEXT;
+12 -42
View File
@@ -8,7 +8,7 @@ use axum::{
use tower::{ServiceBuilder, service_fn};
use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer, trace::TraceLayer};
use crate::{api, auth, db, state::SharedState, websocket};
use crate::{api, assets, auth, db, state::SharedState, websocket};
use std::convert::Infallible;
pub fn router(
@@ -176,12 +176,13 @@ async fn health() -> &'static str {
}
async fn home(State(state): State<SharedState>) -> Response {
versioned_html(
assets::render_html(
include_str!("../static/home.html"),
&state.asset_version,
state.registration_enabled,
&state.frontend_log_level,
state.upload_max_size_bytes,
"home",
)
}
@@ -190,12 +191,13 @@ async fn pad(State(state): State<SharedState>, Path(slug): Path<String>) -> Resp
Ok(Some(pad)) => {
let html = include_str!("../static/pad.html")
.replace("__PAD_TITLE__", &escape_html(&pad.title));
versioned_html(
assets::render_html(
&html,
&state.asset_version,
state.registration_enabled,
&state.frontend_log_level,
state.upload_max_size_bytes,
"pad",
)
}
Ok(None) => error_response(
@@ -216,12 +218,13 @@ async fn pad(State(state): State<SharedState>, Path(slug): Path<String>) -> Resp
async fn public_page(State(state): State<SharedState>, Path(token): Path<String>) -> Response {
match db::find_published_page(&state.db, &token).await {
Ok(Some(_)) => versioned_html(
Ok(Some(_)) => assets::render_html(
include_str!("../static/public.html"),
&state.asset_version,
state.registration_enabled,
&state.frontend_log_level,
state.upload_max_size_bytes,
"public",
),
Ok(None) => error_response(
StatusCode::NOT_FOUND,
@@ -247,12 +250,13 @@ async fn workspace(
Ok(Some(workspace)) => {
let html = include_str!("../static/workspace.html")
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title));
versioned_html(
assets::render_html(
&html,
&state.asset_version,
state.registration_enabled,
&state.frontend_log_level,
state.upload_max_size_bytes,
"workspace",
)
}
Ok(None) => error_response(
@@ -300,12 +304,13 @@ async fn note(
.replace("__NOTE_TITLE__", &escape_html(&note.title))
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title))
.replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug));
versioned_html(
assets::render_html(
&html,
&state.asset_version,
state.registration_enabled,
&state.frontend_log_level,
state.upload_max_size_bytes,
"note",
)
}
Ok(None) => error_response(
@@ -382,7 +387,7 @@ fn error_response(
asset_version: &str,
) -> Response {
let html = include_str!("../static/error.html")
.replace("__ASSET_VERSION__", &escape_html(asset_version))
.replace("__APP_STYLESHEET__", &assets::stylesheet_tag(asset_version, "styles"))
.replace("__ERROR_CODE__", &escape_html(code))
.replace("__ERROR_TITLE__", &escape_html(title))
.replace("__ERROR_MESSAGE__", &escape_html(message))
@@ -394,41 +399,6 @@ fn error_response(
response
}
fn versioned_html(
template: &str,
asset_version: &str,
registration_enabled: bool,
frontend_log_level: &str,
upload_max_size_bytes: usize,
) -> Response {
let frontend_config = format!(
r#"<script>window.__RUSTPAD_CONFIG__=Object.freeze({{frontendLogLevel:"{}",uploadMaxSizeBytes:{}}});</script>"#,
escape_js_string(frontend_log_level),
upload_max_size_bytes,
);
let html = template
.replace("__ASSET_VERSION__", asset_version)
.replace(
"__REGISTRATION_ENABLED__",
if registration_enabled {
"true"
} else {
"false"
},
)
.replace("</head>", &format!("{frontend_config}</head>"));
let mut response = Html(html).into_response();
no_store(&mut response);
response
}
fn escape_js_string(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('<', "\\u003c")
}
fn no_store(response: &mut Response) {
response.headers_mut().insert(
header::CACHE_CONTROL,
+114
View File
@@ -0,0 +1,114 @@
use axum::{
http::{HeaderValue, header},
response::{Html, IntoResponse, Response},
};
const MODULES: &[&str] = &[
"api",
"auth-ui",
"clipboard",
"editor-format",
"emoji-data",
"emoji-picker",
"image-upload",
"logger",
"markdown",
"modal",
"session",
"socket",
"url-state",
];
pub fn render_html(
template: &str,
asset_version: &str,
registration_enabled: bool,
frontend_log_level: &str,
upload_max_size_bytes: usize,
entrypoint: &str,
) -> Response {
let urls = AssetUrls::new(asset_version);
let frontend_config = frontend_config(frontend_log_level, upload_max_size_bytes);
let html = template
.replace("__APP_STYLESHEET__", &urls.stylesheet("styles"))
.replace("__APP_IMPORT_MAP__", &urls.import_map())
.replace("__APP_ENTRYPOINT__", &urls.entrypoint(entrypoint))
.replace(
"__REGISTRATION_ENABLED__",
if registration_enabled {
"true"
} else {
"false"
},
)
.replace("</head>", &format!("{frontend_config}</head>"));
let mut response = Html(html).into_response();
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, no-store"),
);
response
}
pub fn stylesheet_tag(asset_version: &str, name: &str) -> String {
AssetUrls::new(asset_version).stylesheet(name)
}
fn frontend_config(frontend_log_level: &str, upload_max_size_bytes: usize) -> String {
format!(
r#"<script>window.__RUSTPAD_CONFIG__=Object.freeze({{frontendLogLevel:"{}",uploadMaxSizeBytes:{}}});</script>"#,
escape_js_string(frontend_log_level),
upload_max_size_bytes,
)
}
struct AssetUrls<'a> {
version: &'a str,
}
impl<'a> AssetUrls<'a> {
fn new(version: &'a str) -> Self {
Self { version }
}
fn url(&self, path: &str) -> String {
format!("/assets/{path}?v={}", self.version)
}
fn stylesheet(&self, name: &str) -> String {
format!(
r#"<link rel="stylesheet" href="{}">"#,
self.url(&format!("css/{name}.css"))
)
}
fn entrypoint(&self, name: &str) -> String {
format!(
r#"<script type="module" src="{}"></script>"#,
self.url(&format!("js/{name}.js"))
)
}
fn import_map(&self) -> String {
let imports = MODULES
.iter()
.map(|module| {
format!(
r#""@rustpad/{module}":"{}""#,
self.url(&format!("js/{module}.js"))
)
})
.collect::<Vec<_>>()
.join(",");
format!(r#"<script type="importmap">{{"imports":{{{imports}}}}}</script>"#)
}
}
fn escape_js_string(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('<', "\\u003c")
}
+5 -5
View File
@@ -873,10 +873,9 @@ pub async fn resource_sharing(
.fetch_all(state.db.pool())
.await
.map_err(AuthError::database)?;
let links: Vec<(String, String, Option<String>, String)> = sqlx::query_as(queries::get(
state.db.kind(),
queries::RESOURCE_SHARING_LINKS,
))
let links: Vec<(String, Option<String>, String, Option<String>, String)> = sqlx::query_as(
queries::get(state.db.kind(), queries::RESOURCE_SHARING_LINKS),
)
.bind(kind)
.bind(slug)
.fetch_all(state.db.pool())
@@ -892,7 +891,7 @@ pub async fn resource_sharing(
.await
.map_err(AuthError::database)?;
Ok(Json(
serde_json::json!({"users":users.into_iter().map(|(email,nickname,permission)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission})).collect::<Vec<_>>(), "pending":pending.into_iter().map(|(email,nickname,permission,expires_at)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission,"expires_at":expires_at})).collect::<Vec<_>>(), "links":links.into_iter().map(|(token,permission,expires_at,created_at)|serde_json::json!({"token":token,"permission":permission,"expires_at":expires_at,"created_at":created_at})).collect::<Vec<_>>() }),
serde_json::json!({"users":users.into_iter().map(|(email,nickname,permission)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission})).collect::<Vec<_>>(), "pending":pending.into_iter().map(|(email,nickname,permission,expires_at)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission,"expires_at":expires_at})).collect::<Vec<_>>(), "links":links.into_iter().map(|(token_hash,token,permission,expires_at,created_at)|serde_json::json!({"token_hash":token_hash,"token":token,"permission":permission,"expires_at":expires_at,"created_at":created_at})).collect::<Vec<_>>() }),
))
}
@@ -909,6 +908,7 @@ pub async fn create_share_link(
let token_hash = hash_token(&token);
sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_INSERT))
.bind(token_hash)
.bind(&token)
.bind(&req.kind)
.bind(req.slug.trim())
.bind(permission)
+1
View File
@@ -1,5 +1,6 @@
mod api;
mod app;
mod assets;
mod auth;
mod config;
mod database;
+17 -34
View File
@@ -28,8 +28,7 @@ pub const AUTH_FIND_CONFIRMATION_TOKEN: &str =
"SELECT user_id, expires_at, used_at FROM account_confirmation_tokens WHERE token = ?";
pub const AUTH_CONFIRM_USER: &str =
"UPDATE users SET confirmed_at = ?, updated_at = ? WHERE id = ?";
pub const AUTH_CONSUME_CONFIRMATION_TOKEN: &str =
"UPDATE account_confirmation_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL AND expires_at > ?";
pub const AUTH_CONSUME_CONFIRMATION_TOKEN: &str = "UPDATE account_confirmation_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL AND expires_at > ?";
pub const AUTH_DELETE_RESET_TOKENS_BY_USER: &str =
"DELETE FROM password_reset_tokens WHERE user_id = ?";
pub const AUTH_INSERT_RESET_TOKEN: &str =
@@ -38,8 +37,7 @@ pub const AUTH_FIND_RESET_TOKEN: &str =
"SELECT user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?";
pub const AUTH_UPDATE_PASSWORD: &str =
"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?";
pub const AUTH_CONSUME_RESET_TOKEN: &str =
"UPDATE password_reset_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL AND expires_at > ?";
pub const AUTH_CONSUME_RESET_TOKEN: &str = "UPDATE password_reset_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL AND expires_at > ?";
pub const AUTH_DELETE_SESSIONS_BY_USER: &str = "DELETE FROM user_sessions WHERE user_id = ?";
pub const AUTH_USER_BY_SESSION: &str = "SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ?";
pub const AUTH_INSERT_SESSION: &str =
@@ -67,40 +65,25 @@ pub const USER_SET_PAD_PRIVACY: &str =
"UPDATE pads SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?";
pub const RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE: &str =
"DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?";
pub const RESOURCE_ACCESS_TOKENS_INSERT: &str =
"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)";
pub const RESOURCE_ACCESS_TOKENS_VALID_COUNT: &str =
"SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND expires_at > ?";
pub const RESOURCE_PERMISSION_DELETE_USER: &str =
"DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?";
pub const RESOURCE_PERMISSION_INSERT: &str =
"INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)";
pub const SHARE_INVITATION_DELETE_USER: &str =
"DELETE FROM resource_share_invitations WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?";
pub const SHARE_INVITATION_INSERT: &str =
"INSERT INTO resource_share_invitations (token_hash, resource_kind, resource_slug, user_id, permission, created_by, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)";
pub const RESOURCE_ACCESS_TOKENS_INSERT: &str = "INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)";
pub const RESOURCE_ACCESS_TOKENS_VALID_COUNT: &str = "SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND expires_at > ?";
pub const RESOURCE_PERMISSION_DELETE_USER: &str = "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?";
pub const RESOURCE_PERMISSION_INSERT: &str = "INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)";
pub const SHARE_INVITATION_DELETE_USER: &str = "DELETE FROM resource_share_invitations WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?";
pub const SHARE_INVITATION_INSERT: &str = "INSERT INTO resource_share_invitations (token_hash, resource_kind, resource_slug, user_id, permission, created_by, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)";
pub const SHARE_INVITATION_DELETE_TOKEN: &str =
"DELETE FROM resource_share_invitations WHERE token_hash = ?";
pub const SHARE_INVITATION_FIND_TOKEN: &str =
"SELECT resource_kind, resource_slug, user_id, permission, expires_at, accepted_at FROM resource_share_invitations WHERE token_hash = ?";
pub const SHARE_INVITATION_FIND_TOKEN: &str = "SELECT resource_kind, resource_slug, user_id, permission, expires_at, accepted_at FROM resource_share_invitations WHERE token_hash = ?";
pub const SHARE_INVITATION_ACCEPT: &str =
"UPDATE resource_share_invitations SET accepted_at = ? WHERE token_hash = ?";
pub const RESOURCE_SHARING_USERS: &str =
"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email";
pub const RESOURCE_SHARING_LINKS: &str =
"SELECT token_hash, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC";
pub const RESOURCE_SHARING_PENDING: &str =
"SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email";
pub const SHARE_LINK_INSERT: &str =
"INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)";
pub const SHARE_LINK_UPDATE: &str =
"UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL";
pub const SHARE_LINK_REVOKE: &str =
"UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?";
pub const RESOURCE_PERMISSION_BY_USER: &str =
"SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?";
pub const SHARE_LINK_PERMISSION: &str =
"SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)";
pub const RESOURCE_SHARING_USERS: &str = "SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email";
pub const RESOURCE_SHARING_LINKS: &str = "SELECT token_hash, token, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC";
pub const RESOURCE_SHARING_PENDING: &str = "SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email";
pub const SHARE_LINK_INSERT: &str = "INSERT INTO resource_share_links (token_hash, token, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)";
pub const SHARE_LINK_UPDATE: &str = "UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL";
pub const SHARE_LINK_REVOKE: &str = "UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?";
pub const RESOURCE_PERMISSION_BY_USER: &str = "SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?";
pub const SHARE_LINK_PERMISSION: &str = "SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)";
pub const Q001: &str = "SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM workspaces WHERE slug = ?";
pub const Q002: &str = "INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)";
+28 -4
View File
@@ -22,6 +22,7 @@ enum ClientMessage {
access_token: Option<String>,
nickname: Option<String>,
session_token: Option<String>,
guest_id: Option<String>,
color: Option<String>,
},
Update {
@@ -103,19 +104,22 @@ async fn handle_socket(
let _ = send_error(&mut socket, "Note not found").await;
return;
};
let (password, access_token, nickname, session_token, color) = match socket.recv().await {
let (password, access_token, nickname, session_token, guest_id, color) =
match socket.recv().await {
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Authenticate {
password,
access_token,
nickname,
session_token,
guest_id,
color,
}) => (
password,
access_token,
clean_nickname(nickname),
session_token,
clean_guest_id(guest_id),
clean_color(color),
),
_ => {
@@ -138,7 +142,11 @@ async fn handle_socket(
.ok()
.flatten()
.map(|user| format!("user:{}", user.id)),
None => None,
None => guest_id.as_ref().and_then(|id| {
nickname
.as_ref()
.map(|name| format!("guest:{id}:{}", name.to_lowercase()))
}),
};
let permission = auth::resource_permission(
&state,
@@ -234,6 +242,15 @@ fn clean_nickname(value: Option<String>) -> Option<String> {
.map(|v| v.trim().chars().take(40).collect::<String>())
.filter(|v| !v.is_empty())
}
fn clean_guest_id(value: Option<String>) -> Option<String> {
value
.map(|v| v.trim().chars().take(64).collect::<String>())
.filter(|v| {
v.len() >= 16
&& v.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
})
}
fn clean_color(value: Option<String>) -> Option<String> {
value.map(|v| v.trim().to_ascii_lowercase()).filter(|v| {
v.len() == 7 && v.starts_with('#') && v[1..].chars().all(|c| c.is_ascii_hexdigit())
@@ -331,19 +348,22 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
.await;
return;
};
let (password, access_token, nickname, session_token, color) = match socket.recv().await {
let (password, access_token, nickname, session_token, guest_id, color) =
match socket.recv().await {
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Authenticate {
password,
access_token,
nickname,
session_token,
guest_id,
color,
}) => (
password,
access_token,
clean_nickname(nickname),
session_token,
clean_guest_id(guest_id),
clean_color(color),
),
_ => {
@@ -372,7 +392,11 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
.ok()
.flatten()
.map(|user| format!("user:{}", user.id)),
None => None,
None => guest_id.as_ref().and_then(|id| {
nickname
.as_ref()
.map(|name| format!("guest:{id}:{}", name.to_lowercase()))
}),
};
let permission = auth::resource_permission(
&state,
+188
View File
@@ -3869,3 +3869,191 @@ dialog::backdrop {
.markdown-body .contains-task-items>.task-list-item {
list-style: none;
}
/* Keep the editor gutter inside the mobile workspace. */
@media (max-width: 720px) {
.pad-page .editor-shell {
grid-template-columns: 38px minmax(0, 1fr);
min-width: 0;
max-width: 100%;
}
.pad-page .line-gutter {
width: 38px;
min-width: 0;
padding-right: 0;
overflow: hidden;
}
.pad-page .line-gutter div {
padding-right: 6px;
}
.pad-page .owner-labels {
left: 38px;
}
.pad-page.hide-editor-line-numbers .editor-shell {
grid-template-columns: 0 minmax(0, 1fr);
}
}
.emoji-picker {
position: relative;
}
.emoji-picker > summary {
display: flex;
align-items: center;
min-height: 34px;
padding: 0 9px;
border: 1px solid transparent;
border-radius: 7px;
color: #b8c0cc;
font-size: .78rem;
list-style: none;
cursor: pointer;
white-space: nowrap;
}
.emoji-picker > summary::-webkit-details-marker {
display: none;
}
.emoji-picker > summary:hover,
.emoji-picker[open] > summary {
border-color: var(--border);
background: var(--surface-2);
color: white;
}
.emoji-picker-panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 40;
width: min(360px, calc(100vw - 24px));
padding: 10px;
border: 1px solid var(--border-strong);
border-radius: 10px;
background: #11151c;
box-shadow: 0 14px 40px rgba(0, 0, 0, .45);
}
.emoji-search {
width: 100%;
min-height: 36px;
margin: 0 0 8px;
}
.emoji-categories {
display: flex;
gap: 3px;
padding-bottom: 7px;
overflow-x: auto;
scrollbar-width: thin;
}
.editor-toolbar .emoji-category,
.editor-toolbar .emoji-item {
display: grid;
place-items: center;
flex: 0 0 auto;
padding: 0;
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
}
.editor-toolbar .emoji-category {
width: 31px;
min-height: 31px;
border-color: transparent;
font-size: 1rem;
}
.editor-toolbar .emoji-category[aria-pressed="true"] {
border-color: var(--border-strong);
background: var(--surface-2);
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 3px;
max-height: 290px;
overflow-y: auto;
overscroll-behavior: contain;
}
.editor-toolbar .emoji-item {
width: 100%;
min-height: 38px;
border-color: transparent;
font-size: 1.45rem;
line-height: 1;
}
.editor-toolbar .emoji-item:hover,
.editor-toolbar .emoji-item:focus-visible {
border-color: var(--border);
background: var(--surface-2);
transform: scale(1.08);
}
.emoji-empty {
margin: 18px 0 10px;
color: var(--muted);
text-align: center;
font-size: .82rem;
}
@media (max-width: 720px) {
.emoji-picker-panel {
position: fixed;
left: 12px;
right: 12px;
top: auto;
bottom: 58px;
width: auto;
}
.emoji-grid {
grid-template-columns: repeat(7, minmax(0, 1fr));
max-height: min(45vh, 320px);
}
}
.share-link-info {
display: grid;
gap: 6px;
}
.share-link-inline {
display: grid;
grid-template-columns: minmax(180px, 1fr) auto;
gap: 7px;
align-items: center;
margin-top: 3px;
}
.share-link-inline input {
width: 100%;
min-width: 0;
min-height: 36px;
padding: 0 10px;
border: 1px solid var(--border-strong);
border-radius: 7px;
background: #10141a;
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: .76rem;
}
.share-link-legacy {
color: var(--warning, #d6a84b) !important;
}
@media (max-width: 800px) {
.share-link-inline {
grid-template-columns: 1fr;
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
<meta name="color-scheme" content="dark">
<meta name="robots" content="noindex">
<title>__ERROR_TITLE__ · RustPad</title>
<link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__">
__APP_STYLESHEET__
</head>
<body>
+3 -4
View File
@@ -6,10 +6,9 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>RustPad</title>
<link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__">
<script
type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script>
<script type="module" src="/assets/js/home.js?v=__ASSET_VERSION__"></script>
__APP_STYLESHEET__
__APP_IMPORT_MAP__
__APP_ENTRYPOINT__
</head>
<body class="home-page" data-registration-enabled="__REGISTRATION_ENABLED__">
+1 -1
View File
@@ -1,4 +1,4 @@
import { logDebug, logError, logWarn } from "./logger.js";
import { logDebug, logError, logWarn } from "@rustpad/logger";
function formatBytes(bytes) {
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes % (1024 * 1024) ? 1 : 0)} MB`;
+1 -1
View File
@@ -1,6 +1,6 @@
import { api } from "@rustpad/api";
import * as sessionStore from "@rustpad/session";
import { askInput, showMessage } from "./modal.js";
import { askInput, showMessage } from "@rustpad/modal";
const { getAuthToken, setAuthSession, setNickname } = sessionStore;
const clearAuthSession = sessionStore.clearAuthSession || (() => {
File diff suppressed because one or more lines are too long
+120
View File
@@ -0,0 +1,120 @@
import { EMOJI_GROUPS } from "@rustpad/emoji-data";
const RECENTS_KEY = "rustpad:recent-emojis";
const MAX_RECENTS = 24;
function loadRecents() {
try {
const values = JSON.parse(localStorage.getItem(RECENTS_KEY) || "[]");
return Array.isArray(values) ? values.filter(value => typeof value === "string").slice(0, MAX_RECENTS) : [];
} catch {
return [];
}
}
function saveRecent(emoji) {
const next = [emoji, ...loadRecents().filter(value => value !== emoji)].slice(0, MAX_RECENTS);
localStorage.setItem(RECENTS_KEY, JSON.stringify(next));
return next;
}
function normalize(value) {
return String(value || "").toLocaleLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function insertAtSelection(editor, text) {
const start = editor.selectionStart;
editor.setRangeText(text, start, editor.selectionEnd, "end");
editor.focus();
editor.dispatchEvent(new Event("input", { bubbles: true }));
}
export function bindEmojiPicker({ editor, details, search, categories, grid, empty }) {
if (!editor || !details || !search || !categories || !grid) return;
let activeGroup = EMOJI_GROUPS[0]?.name || "";
let recents = loadRecents();
const groups = () => {
const recentItems = recents.map(value => {
for (const group of EMOJI_GROUPS) {
const found = group.items.find(item => item.emoji === value);
if (found) return found;
}
return { emoji: value, name: "Recent emoji", keywords: "recent" };
});
return recentItems.length ? [{ name: "Recently Used", items: recentItems }, ...EMOJI_GROUPS] : EMOJI_GROUPS;
};
function renderCategories() {
const available = groups();
if (!available.some(group => group.name === activeGroup)) activeGroup = available[0]?.name || "";
categories.replaceChildren(...available.map(group => {
const button = document.createElement("button");
button.type = "button";
button.className = "emoji-category";
button.dataset.emojiGroup = group.name;
button.textContent = group.items[0]?.emoji || "•";
button.title = group.name;
button.setAttribute("aria-label", group.name);
button.setAttribute("aria-pressed", String(group.name === activeGroup));
return button;
}));
}
function renderGrid() {
const query = normalize(search.value.trim());
let items;
if (query) {
items = EMOJI_GROUPS.flatMap(group => group.items).filter(item =>
normalize(`${item.name} ${item.keywords}`).includes(query)
);
} else {
items = groups().find(group => group.name === activeGroup)?.items || [];
}
const fragment = document.createDocumentFragment();
for (const item of items) {
const button = document.createElement("button");
button.type = "button";
button.className = "emoji-item";
button.dataset.emoji = item.emoji;
button.title = item.name;
button.setAttribute("aria-label", item.name);
button.textContent = item.emoji;
fragment.append(button);
}
grid.replaceChildren(fragment);
if (empty) empty.hidden = items.length > 0;
}
function render() {
renderCategories();
renderGrid();
}
details.addEventListener("toggle", () => {
if (!details.open) return;
render();
requestAnimationFrame(() => search.focus());
});
search.addEventListener("input", renderGrid);
categories.addEventListener("click", event => {
const button = event.target.closest("[data-emoji-group]");
if (!button) return;
activeGroup = button.dataset.emojiGroup;
search.value = "";
render();
});
grid.addEventListener("click", event => {
const button = event.target.closest("[data-emoji]");
if (!button) return;
insertAtSelection(editor, button.dataset.emoji);
recents = saveRecent(button.dataset.emoji);
renderCategories();
});
document.addEventListener("pointerdown", event => {
if (details.open && !details.contains(event.target)) details.removeAttribute("open");
});
}
+10 -4
View File
@@ -1,9 +1,10 @@
import { installGlobalDiagnostics, logInfo } from "./logger.js";
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();
import { bindIdentityDialog, handleAccountConfirmationToken, handleResetToken, logoutCurrentSession, validateCurrentSession } from "./auth-ui.js";
import { bindIdentityDialog, handleAccountConfirmationToken, handleResetToken, logoutCurrentSession, validateCurrentSession } from "@rustpad/auth-ui";
import { getAuthToken, setAccessToken } from "@rustpad/session";
import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
function slugify(value, fallback) {
return value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || fallback;
@@ -164,16 +165,21 @@ async function loadResources() {
const refresh = async () => {
const d = await api(`/api/auth/resources/sharing?kind=${encodeURIComponent(item.kind)}&slug=${encodeURIComponent(item.slug)}`, { headers: authHeaders() });
userList.innerHTML = d.users.length ? d.users.map(u => `<div class="share-list-row"><div class="share-list-identity"><span class="share-avatar">${escapeHtml((u.nickname || u.email || "?").slice(0, 1).toUpperCase())}</span><div><strong>${escapeHtml(u.nickname)}</strong><small>${escapeHtml(u.email)}</small></div></div><span class="share-role">${u.permission === "rw" ? "Read and write" : "Read only"}</span><button class="secondary-button compact-button" type="button" data-remove-user="${escapeHtml(u.email)}">Remove</button></div>`).join("") : '<p class="share-empty">No users have access.</p>';
linkList.innerHTML=d.links.length?d.links.map(link=>`<form class="share-list-row share-link-row" data-link-token="${escapeHtml(link.token)}"><div class="share-link-info"><strong>Link ${escapeHtml(link.token.slice(0,8))}…</strong><small>${escapeHtml(formatShareExpiry(link.expires_at))}</small></div><label><span class="sr-only">Permission</span><select name="permission" aria-label="Link permission"><option value="ro" ${link.permission==="ro"?"selected":""}>Read only</option><option value="rw" ${link.permission==="rw"?"selected":""}>Read and write</option></select></label><label><span class="sr-only">Validity in hours</span><div class="share-hours-field"><input name="hours" type="number" min="1" max="87600" value="24" aria-label="New validity in hours"><span>h</span></div></label><label class="share-forever"><input name="forever" type="checkbox" ${link.expires_at?"":"checked"}><span>Never</span></label><div class="share-row-actions"><button class="secondary-button compact-button" type="submit">Update</button><button class="danger-button compact-button" type="button" data-revoke-link>Revoke</button></div></form>`).join(""):'<p class="share-empty">No active links.</p>';
linkList.innerHTML = d.links.length ? d.links.map(link => {
const directUrl = link.token ? new URL(`${item.url}?share=${encodeURIComponent(link.token)}`, location.origin).href : "";
const linkPreview = link.token ? `<div class="share-link-inline"><input type="text" readonly value="${escapeHtml(directUrl)}" aria-label="Direct access link"><button class="secondary-button compact-button" type="button" data-copy-link>Copy</button></div>` : '<small class="share-link-legacy">Link value unavailable. Recreate this legacy link to display it.</small>';
return `<form class="share-list-row share-link-row" data-link-token="${escapeHtml(link.token_hash)}"><div class="share-link-info"><strong>Individual link</strong><small>${escapeHtml(formatShareExpiry(link.expires_at))}</small>${linkPreview}</div><label><span class="sr-only">Permission</span><select name="permission" aria-label="Link permission"><option value="ro" ${link.permission === "ro" ? "selected" : ""}>Read only</option><option value="rw" ${link.permission === "rw" ? "selected" : ""}>Read and write</option></select></label><label><span class="sr-only">Validity in hours</span><div class="share-hours-field"><input name="hours" type="number" min="1" max="87600" value="24" aria-label="New validity in hours"><span>h</span></div></label><label class="share-forever"><input name="forever" type="checkbox" ${link.expires_at ? "" : "checked"}><span>Never</span></label><div class="share-row-actions"><button class="secondary-button compact-button" type="submit">Update</button><button class="danger-button compact-button" type="button" data-revoke-link>Revoke</button></div></form>`;
}).join("") : '<p class="share-empty">No active links.</p>';
userList.querySelectorAll("[data-remove-user]").forEach(button => button.addEventListener("click", async () => { try { button.disabled = true; await api("/api/auth/resources/sharing", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, email: button.dataset.removeUser }) }); setDialogMessage("Access removed.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); button.disabled = false; } }));
linkList.querySelectorAll("[data-link-token]").forEach(linkRow => {
const forever = linkRow.elements.forever, hours = linkRow.elements.hours; const sync = () => { hours.disabled = forever.checked; }; forever.addEventListener("change", sync); sync();
linkRow.querySelector("[data-copy-link]")?.addEventListener("click", async () => { try { await copyText(linkRow.querySelector(".share-link-inline input").value); setDialogMessage("Link copied.", "success"); } catch (err) { setDialogMessage(err.message, "error"); } });
linkRow.addEventListener("submit", async event => { event.preventDefault(); try { const expires_at = shareExpiry(hours.value, forever.checked); await api("/api/auth/resources/share-links", { method: "PUT", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token: linkRow.dataset.linkToken, permission: linkRow.elements.permission.value, expires_at }) }); setDialogMessage("Link updated.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
linkRow.querySelector("[data-revoke-link]").addEventListener("click", async () => { try { await api("/api/auth/resources/share-links", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token: linkRow.dataset.linkToken }) }); setDialogMessage("Link revoked.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
});
};
userForm.addEventListener("submit", async event => { event.preventDefault(); try { await api("/api/auth/resources/sharing", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, emails: userForm.emails.value, permission: userForm.permission.value }) }); userForm.emails.value = ""; setDialogMessage("Access granted.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
linkForm.addEventListener("submit",async event=>{event.preventDefault();try{const expires_at=shareExpiry(linkForm.hours.value,linkForm.forever.checked);const result=await api("/api/auth/resources/share-links",{method:"POST",headers:authHeaders(),body:JSON.stringify({kind:item.kind,slug:item.slug,permission:linkForm.permission.value,expires_at})});const absolute=new URL(result.url,location.origin).href;await navigator.clipboard.writeText(absolute);setDialogMessage("Link created and copied.","success");await refresh();}catch(err){setDialogMessage(err.message,"error");}});
linkForm.addEventListener("submit", async event => { event.preventDefault(); try { const expires_at = shareExpiry(linkForm.hours.value, linkForm.forever.checked); const result = await api("/api/auth/resources/share-links", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, permission: linkForm.permission.value, expires_at }) }); const absolute = new URL(result.url, location.origin).href; await copyText(absolute); setDialogMessage("Link created and copied. It remains visible below.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
dialog.showModal();
try { await refresh(); } catch (err) { setDialogMessage(err.message, "error"); }
});
+3 -7
View File
@@ -1,3 +1,5 @@
import { EMOJI_SHORTCODES } from "@rustpad/emoji-data";
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[c]));
}
@@ -8,13 +10,7 @@ function safeUrl(value) {
return "#";
}
const emoji = {
smile:"😄", joy:"😂", heart:"❤️", thumbs_up:"👍", thumbsup:"👍", thumbs_down:"👎",
fire:"🔥", rocket:"🚀", tada:"🎉", warning:"⚠️", white_check_mark:"✅", x:"❌",
eyes:"👀", bulb:"💡", memo:"📝", pushpin:"📌", bug:"🐛", sparkles:"✨",
thinking:"🤔", clap:"👏", ok_hand:"👌", pray:"🙏", muscle:"💪", coffee:"☕",
tent:"⛺", star:"⭐", checkered_flag:"🏁"
};
const emoji = EMOJI_SHORTCODES;
function inline(value) {
const tokens = [];
+8 -7
View File
@@ -1,15 +1,16 @@
import { installGlobalDiagnostics, logInfo } from "./logger.js";
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();
import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
import { bindEmojiPicker } from "@rustpad/emoji-picker";
import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown";
import { prepareImageFile } from "./image-upload.js";
import { getNickname, getAccessToken, getAuthToken, setAccessToken } from "@rustpad/session";
import { bindIdentityDialog } from "./auth-ui.js";
import { prepareImageFile } from "@rustpad/image-upload";
import { getNickname, getGuestId, getAccessToken, getAuthToken, setAccessToken } from "@rustpad/session";
import { bindIdentityDialog } from "@rustpad/auth-ui";
import { NoteSocket } from "@rustpad/socket";
import { askConfirm } from "./modal.js";
import { askConfirm } from "@rustpad/modal";
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
const parts = location.pathname.split("/").filter(Boolean), workspaceSlug = parts[1], noteSlug = parts[3];
@@ -141,7 +142,7 @@ function replaceTableCell(line,index,value){
function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Markdown + Mermaid preview · text and headings are editable"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}" contenteditable="true" spellcheck="true">${escapeHtml(line) || "<br>"}</div>`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); }
function applyUi({ write = false, replace = false } = {}) { editorWorkspace.className = `workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`; editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`); document.body.classList.toggle("compact-editor", compactToggle.checked); document.querySelectorAll("[data-view]").forEach(b => { const a = b.dataset.view === uiState.view; b.classList.toggle("active", a); b.setAttribute("aria-pressed", String(a)); }); const markdown = uiState.mode === "markdown"; modeToggle.classList.toggle("active", markdown); modeToggle.textContent = markdown ? "Markdown" : "Text"; render(); if (write) writeEditorState(uiState, { replace }); updateAddressLabel(); }
function applyRemote(content, ownerMap) { if (content === editor.value && ownerMap == null) return; const start = editor.selectionStart, end = editor.selectionEnd; applyingRemote = true; editor.value = content; try { owners = JSON.parse(ownerMap || "[]"); } catch { owners = []; } editor.setSelectionRange(Math.min(start, content.length), Math.min(end, content.length)); applyingRemote = false; render(); }
function connect(){socket?.stop();socket=new NoteSocket({workspaceSlug,noteSlug,password,accessToken,nickname,color:currentUserColor()||null,sessionToken:getAuthToken(),onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onPresence:updatePresence,onLatency:updateLatency,onChat:appendChatMessage,onError:m=>{document.querySelector("#password-error").textContent=m;if(/nickname|session|account/i.test(m)){if(!identityDialog.open)identityDialog.showModal();}else if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();}
function connect() { socket?.stop(); socket = new NoteSocket({ workspaceSlug, noteSlug, password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { if (passwordDialog.open) passwordDialog.close(); applyRemote(m.content, m.owner_map); editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { document.querySelector("#password-error").textContent = m; if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); }
function formatBytes(bytes) { const value = Math.max(0, Number(bytes) || 0), units = ["B", "KB", "MB", "GB", "TB"]; let size = value, index = 0; while (size >= 1024 && index < units.length - 1) { size /= 1024; index++; } return `${index === 0 ? Math.round(size) : size.toFixed(size >= 10 ? 1 : 2)} ${units[index]}`; }
async function loadFiles({ open = false } = {}) {
@@ -159,7 +160,7 @@ identityDialog.addEventListener("close",()=>{if(!nickname)queueMicrotask(()=>{if
async function initialize() { try { info = await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`, { headers: getAuthToken() ? { Authorization: `Bearer ${getAuthToken()}` } : {} }); document.title = `${info.title} · ${info.workspace_title}`; publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); applyUi({ write: true, replace: true }); if (!nickname) { identityDialog.showModal(); return; } updateCurrentUser(); document.querySelector("#delete-note").hidden = info.note_protected; if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } catch (e) { document.body.innerHTML = `<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`; } }
document.querySelectorAll("[data-view]").forEach(b => b.addEventListener("click", () => { uiState = { ...uiState, view: b.dataset.view }; applyUi({ write: true }); })); modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); }); lineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:line-numbers", lineToggle.checked ? "on" : "off"); renderGutter(); }); previewLineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:preview-line-numbers", previewLineToggle.checked ? "on" : "off"); renderGutter(); }); compactToggle.addEventListener("change", () => { localStorage.setItem("rustpad:compact", compactToggle.checked ? "on" : "off"); applyUi(); }); fontFamily.addEventListener("change", () => { localStorage.setItem("rustpad:font-family", fontFamily.value); applyUi(); }); fontSize.addEventListener("change", () => { localStorage.setItem("rustpad:font-size", fontSize.value); applyUi(); });
window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>{applyFormat(editor,b.dataset.format);b.closest("details")?.removeAttribute("open");}));bindFormatShortcuts(editor);document.querySelector("#shortcuts-button").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").showModal());document.querySelector("#close-shortcuts").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").close());preview.addEventListener("change",event=>{const checkbox=event.target.closest(".task-checkbox");if(!checkbox)return;const lineIndex=Number(checkbox.dataset.sourceLine)-1;const lines=editor.value.split("\n");if(lineIndex<0||lineIndex>=lines.length)return;lines[lineIndex]=lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/,`$1${checkbox.checked?"x":" "}$2`);editor.value=lines.join("\n");editor.dispatchEvent(new Event("input",{bubbles:true}));});preview.addEventListener("keydown",event=>{const target=event.target.closest(".preview-editable");if(!target)return;if(event.key==="Enter"){event.preventDefault();target.blur();return;}if(event.key==="ArrowUp"||event.key==="ArrowDown"){if(movePreviewCaret(target,event.key==="ArrowUp"?-1:1))event.preventDefault();}});preview.addEventListener("blur",event=>{const target=event.target.closest(".preview-editable");if(!target)return;const lineIndex=Number(target.dataset.sourceLine)-1;if(lineIndex<0)return;const lines=editor.value.split("\n");const value=markdownFromPreview(target);let next;if(target.dataset.tableCell!==undefined)next=replaceTableCell(lines[lineIndex],Number(target.dataset.tableCell),value);else{const prefix=target.dataset.sourcePrefix||"",suffix=target.dataset.sourceSuffix||"";next=prefix+value+suffix;}if(lines[lineIndex]===next)return;lines[lineIndex]=next;editor.value=lines.join("\n");editor.setSelectionRange(editor.value.length,editor.value.length);editor.dispatchEvent(new Event("input",{bubbles:true}));},{capture:true});
window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); document.querySelector("#copy-link").addEventListener("click", async () => { try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } catch (e) { toast(e.message); } }); document.querySelectorAll("[data-format]").forEach(b => b.addEventListener("click", () => { applyFormat(editor, b.dataset.format); b.closest("details")?.removeAttribute("open"); })); bindFormatShortcuts(editor); bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") }); document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); if (!checkbox) return; const lineIndex = Number(checkbox.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`); editor.value = lines.join("\n"); editor.dispatchEvent(new Event("input", { bubbles: true })); }); preview.addEventListener("keydown", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (event.key === "Enter") { event.preventDefault(); target.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); preview.addEventListener("blur", event => { const target = event.target.closest(".preview-editable"); if (!target) return; const lineIndex = Number(target.dataset.sourceLine) - 1; if (lineIndex < 0) return; const lines = editor.value.split("\n"); const value = markdownFromPreview(target); let next; if (target.dataset.tableCell !== undefined) next = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value); else { const prefix = target.dataset.sourcePrefix || "", suffix = target.dataset.sourceSuffix || ""; next = prefix + value + suffix; } if (lines[lineIndex] === next) return; lines[lineIndex] = next; editor.value = lines.join("\n"); editor.setSelectionRange(editor.value.length, editor.value.length); editor.dispatchEvent(new Event("input", { bubbles: true })); }, { capture: true });
publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: publicTaskUpdates.checked }) }); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { publicTaskUpdates.disabled = false; } }); document.querySelector("#publish-page").addEventListener("click", async () => { try { const result = await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: publicTaskUpdates.checked }) }); const url = new URL(result.url, location.origin).href; await copyText(url); toast("Page link copied"); window.open(url, "_blank", "noopener"); } catch (error) { toast(error.message); } });
roomDetails.addEventListener("toggle", () => { if (roomDetails.open) { clearUnread(); chatInput.focus(); if ("Notification" in window && Notification.permission === "default") Notification.requestPermission().catch(() => { }); } });
document.addEventListener("visibilitychange", () => { if (!document.hidden && roomDetails.open) clearUnread(); });
+5 -5
View File
@@ -1,13 +1,13 @@
import { installGlobalDiagnostics, logInfo } from "./logger.js";
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();
import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown";
import { prepareImageFile } from "./image-upload.js";
import { getNickname, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
import { bindIdentityDialog } from "./auth-ui.js";
import { prepareImageFile } from "@rustpad/image-upload";
import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
import { bindIdentityDialog } from "@rustpad/auth-ui";
import { PadSocket } from "@rustpad/socket";
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
@@ -149,7 +149,7 @@ async function loadFiles({open=false}={}){
if (open) document.querySelector("#files-dialog").showModal();
} catch (error) { if (open) toast(error.message); }
}
function connect(){socket?.stop();socket=new PadSocket({slug,password,accessToken,nickname,color:currentUserColor()||null,sessionToken:getAuthToken(),onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onPresence:updatePresence,onLatency:updateLatency,onChat:appendChatMessage,onError:m=>{document.querySelector("#password-error").textContent=m;if(/nickname|session|account/i.test(m)){if(!identityDialog.open)identityDialog.showModal();}else if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();}
function connect() { socket?.stop(); socket = new PadSocket({ slug, password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { if (passwordDialog.open) passwordDialog.close(); applyRemote(m.content, m.owner_map); editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { document.querySelector("#password-error").textContent = m; if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); }
bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; identityDialog.close(); updateCurrentUser(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } });
identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
async function initialize() { try { info = await api(`/api/pads/${encodeURIComponent(slug)}`); document.title = `${info.title} · RustPad`; publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); applyUi({ write: true, replace: true }); if (!nickname) { identityDialog.showModal(); return; } updateCurrentUser(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } catch (e) { document.body.innerHTML = `<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`; } }
+1 -1
View File
@@ -1,4 +1,4 @@
import { installGlobalDiagnostics, logInfo } from "./logger.js";
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();
import { api } from "@rustpad/api";
+10
View File
@@ -40,6 +40,16 @@ export function setNickname(value) {
setNicknameCookie(nickname);
}
const GUEST_ID_COOKIE = "rustpad_guest_id";
export function getGuestId() {
let guestId = getCookie(GUEST_ID_COOKIE);
if (!/^[a-zA-Z0-9_-]{16,64}$/.test(guestId)) {
guestId = crypto.randomUUID().replaceAll("-", "");
document.cookie = `${GUEST_ID_COOKIE}=${guestId}; Path=/; SameSite=Lax`;
}
return guestId;
}
const AUTH_TOKEN_KEY = "rustpad:auth-token";
// Account sessions must be shared by every tab on this origin. Keep a
// compatibility fallback for sessions created by older frontend versions.
+2 -2
View File
@@ -1,4 +1,4 @@
import { logError, logInfo, logWarn } from "./logger.js";
import { logError, logInfo, logWarn } from "@rustpad/logger";
class RoomSocket {
constructor(options) {
@@ -19,7 +19,7 @@ class RoomSocket {
this.socket = new WebSocket(this.url);
this.socket.addEventListener("open", () => {
logInfo("websocket.open", { kind: this.kind });
this.send({ type: "authenticate", password: this.password || null, access_token: this.accessToken || null, nickname: this.nickname || null, session_token: this.sessionToken || null, color: this.color || null });
this.send({ type: "authenticate", password: this.password || null, access_token: this.accessToken || null, nickname: this.nickname || null, session_token: this.sessionToken || null, guest_id: this.guestId || null, color: this.color || null });
});
this.socket.addEventListener("message", event => {
let message;
+2 -2
View File
@@ -1,10 +1,10 @@
import { installGlobalDiagnostics, logInfo } from "./logger.js";
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();
import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { getNickname, getAccessToken, getAuthToken, setAccessToken } from "@rustpad/session";
import { askConfirm } from "./modal.js";
import { askConfirm } from "@rustpad/modal";
const parts = location.pathname.split("/").filter(Boolean);
const slug = parts[1];
+12 -4
View File
@@ -6,10 +6,9 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>__NOTE_TITLE__ · RustPad</title>
<link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__">
<script
type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script>
<script type="module" src="/assets/js/note.js?v=__ASSET_VERSION__"></script>
__APP_STYLESHEET__
__APP_IMPORT_MAP__
__APP_ENTRYPOINT__
</head>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__">
@@ -43,6 +42,15 @@
data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button
data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button
data-format="quote">Quote</button><button data-format="link">Link</button>
<details id="emoji-picker" class="emoji-picker">
<summary title="Insert emoji" aria-label="Insert emoji">😀 Emoji</summary>
<div class="emoji-picker-panel">
<input id="emoji-search" class="emoji-search" type="search" placeholder="Search emoji…" autocomplete="off" aria-label="Search emoji">
<div id="emoji-categories" class="emoji-categories" aria-label="Emoji categories"></div>
<div id="emoji-grid" class="emoji-grid" role="listbox" aria-label="Emoji"></div>
<p id="emoji-empty" class="emoji-empty" hidden>No emoji found.</p>
</div>
</details>
<details class="markdown-more">
<summary title="Extended Markdown">More</summary>
<div class="markdown-more-menu"><button type="button" data-format="details">Collapsible
+3 -4
View File
@@ -6,10 +6,9 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>__PAD_TITLE__ · RustPad</title>
<link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__">
<script
type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script>
<script type="module" src="/assets/js/pad.js?v=__ASSET_VERSION__"></script>
__APP_STYLESHEET__
__APP_IMPORT_MAP__
__APP_ENTRYPOINT__
</head>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__">
+3 -4
View File
@@ -6,10 +6,9 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Published note · RustPad</title>
<link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__">
<script
type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script>
<script type="module" src="/assets/js/public.js?v=__ASSET_VERSION__"></script>
__APP_STYLESHEET__
__APP_IMPORT_MAP__
__APP_ENTRYPOINT__
</head>
<body class="public-page hide-preview-line-numbers">
+3 -4
View File
@@ -6,10 +6,9 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>__WORKSPACE_TITLE__ · RustPad</title>
<link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__">
<script
type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script>
<script type="module" src="/assets/js/workspace.js?v=__ASSET_VERSION__"></script>
__APP_STYLESHEET__
__APP_IMPORT_MAP__
__APP_ENTRYPOINT__
</head>
<body>