new functions and js v=
This commit is contained in:
Generated
+1
-1
@@ -2433,7 +2433,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.0.19"
|
||||
version = "0.0.20"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
|
||||
+1
-1
@@ -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
@@ -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(¬e.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
@@ -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
@@ -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,5 +1,6 @@
|
||||
mod api;
|
||||
mod app;
|
||||
mod assets;
|
||||
mod auth;
|
||||
mod config;
|
||||
mod database;
|
||||
|
||||
+17
-34
@@ -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 (?, ?, ?)";
|
||||
|
||||
+76
-52
@@ -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,28 +104,31 @@ 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 {
|
||||
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
|
||||
Ok(ClientMessage::Authenticate {
|
||||
password,
|
||||
access_token,
|
||||
nickname,
|
||||
session_token,
|
||||
color,
|
||||
}) => (
|
||||
password,
|
||||
access_token,
|
||||
clean_nickname(nickname),
|
||||
session_token,
|
||||
clean_color(color),
|
||||
),
|
||||
_ => {
|
||||
let _ = send_error(&mut socket, "Wymagane uwierzytelnienie").await;
|
||||
return;
|
||||
}
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
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),
|
||||
),
|
||||
_ => {
|
||||
let _ = send_error(&mut socket, "Wymagane uwierzytelnienie").await;
|
||||
return;
|
||||
}
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await {
|
||||
Ok(value) => value,
|
||||
Err(message) => {
|
||||
@@ -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,34 +348,37 @@ 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 {
|
||||
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
|
||||
Ok(ClientMessage::Authenticate {
|
||||
password,
|
||||
access_token,
|
||||
nickname,
|
||||
session_token,
|
||||
color,
|
||||
}) => (
|
||||
password,
|
||||
access_token,
|
||||
clean_nickname(nickname),
|
||||
session_token,
|
||||
clean_color(color),
|
||||
),
|
||||
_ => {
|
||||
let _ = send_pad(
|
||||
&mut socket,
|
||||
&PadServerMessage::Error {
|
||||
message: "Wymagane uwierzytelnienie".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
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),
|
||||
),
|
||||
_ => {
|
||||
let _ = send_pad(
|
||||
&mut socket,
|
||||
&PadServerMessage::Error {
|
||||
message: "Wymagane uwierzytelnienie".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await {
|
||||
Ok(value) => value,
|
||||
Err(message) => {
|
||||
@@ -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,
|
||||
|
||||
+189
-1
@@ -3868,4 +3868,192 @@ 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
@@ -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
@@ -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
@@ -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,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
@@ -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");
|
||||
});
|
||||
}
|
||||
+42
-36
@@ -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;
|
||||
@@ -94,14 +95,14 @@ const resourcesList = document.querySelector("#resources-list");
|
||||
const resourcesError = document.querySelector("#resources-error");
|
||||
|
||||
function authHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
|
||||
function escapeHtml(value) { const node=document.createElement("div"); node.textContent=String(value ?? ""); return node.innerHTML; }
|
||||
function shareExpiry(hours, forever) { if (forever) return null; const value=Number(hours); if (!Number.isFinite(value) || value <= 0 || value > 87600) throw new Error("Enter a validity between 1 and 87600 hours."); return new Date(Date.now()+value*3600000).toISOString(); }
|
||||
function formatShareExpiry(value) { if (!value) return "Never expires"; const date=new Date(value); return Number.isNaN(date.getTime()) ? value : `Expires ${date.toLocaleString()}`; }
|
||||
function escapeHtml(value) { const node = document.createElement("div"); node.textContent = String(value ?? ""); return node.innerHTML; }
|
||||
function shareExpiry(hours, forever) { if (forever) return null; const value = Number(hours); if (!Number.isFinite(value) || value <= 0 || value > 87600) throw new Error("Enter a validity between 1 and 87600 hours."); return new Date(Date.now() + value * 3600000).toISOString(); }
|
||||
function formatShareExpiry(value) { if (!value) return "Never expires"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Expires ${date.toLocaleString()}`; }
|
||||
async function loadResources() {
|
||||
resourcesError.textContent = ""; resourcesList.innerHTML = "<p>Loading…</p>";
|
||||
try {
|
||||
const data = await api("/api/auth/resources", { headers: authHeaders() });
|
||||
const items = [...data.workspaces.map(item => ({...item, kind:"workspace", url:`/w/${item.slug}`})), ...data.pads.map(item => ({...item, kind:"pad", url:`/p/${item.slug}`}))];
|
||||
const items = [...data.workspaces.map(item => ({ ...item, kind: "workspace", url: `/w/${item.slug}` })), ...data.pads.map(item => ({ ...item, kind: "pad", url: `/p/${item.slug}` }))];
|
||||
resourcesList.innerHTML = items.length ? "" : "<p>No assigned items yet.</p>";
|
||||
for (const item of items) {
|
||||
const row = document.createElement("article");
|
||||
@@ -121,8 +122,8 @@ async function loadResources() {
|
||||
};
|
||||
|
||||
row.querySelector("[data-privacy]")?.addEventListener("click", async () => {
|
||||
try { await api("/api/auth/resources/privacy", {method:"POST",headers:authHeaders(),body:JSON.stringify({kind:item.kind,slug:item.slug,private:!Boolean(item.private)})}); await loadResources(); }
|
||||
catch(e){ resourcesError.textContent=e.message; }
|
||||
try { await api("/api/auth/resources/privacy", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, private: !Boolean(item.private) }) }); await loadResources(); }
|
||||
catch (e) { resourcesError.textContent = e.message; }
|
||||
});
|
||||
row.querySelector("[data-share]")?.addEventListener("click", async () => {
|
||||
const dialog = document.createElement("dialog");
|
||||
@@ -145,37 +146,42 @@ async function loadResources() {
|
||||
<div class="share-dialog-footer"><p class="form-message resource-inline-message" data-inline-message role="status"></p><button class="secondary-button" type="button" data-done>Done</button></div>
|
||||
</div>`;
|
||||
document.body.append(dialog);
|
||||
const closeDialog=()=>dialog.close();
|
||||
dialog.addEventListener("close",()=>dialog.remove(),{once:true});
|
||||
dialog.addEventListener("click",event=>{if(event.target===dialog) closeDialog();});
|
||||
dialog.querySelector("[data-cancel]").addEventListener("click",closeDialog);
|
||||
dialog.querySelector("[data-done]").addEventListener("click",closeDialog);
|
||||
const closeDialog = () => dialog.close();
|
||||
dialog.addEventListener("close", () => dialog.remove(), { once: true });
|
||||
dialog.addEventListener("click", event => { if (event.target === dialog) closeDialog(); });
|
||||
dialog.querySelector("[data-cancel]").addEventListener("click", closeDialog);
|
||||
dialog.querySelector("[data-done]").addEventListener("click", closeDialog);
|
||||
const setDialogMessage = (text, type = "") => {
|
||||
const message = dialog.querySelector("[data-inline-message]");
|
||||
message.className = `form-message resource-inline-message ${type}`.trim();
|
||||
message.textContent = text;
|
||||
};
|
||||
const userForm=dialog.querySelector("[data-user-share-form]");
|
||||
const linkForm=dialog.querySelector("[data-link-form]");
|
||||
const userList=dialog.querySelector("[data-user-list]");
|
||||
const linkList=dialog.querySelector("[data-link-list]");
|
||||
const syncForever=()=>{linkForm.hours.disabled=linkForm.forever.checked;};
|
||||
linkForm.forever.addEventListener("change",syncForever); syncForever();
|
||||
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>';
|
||||
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.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");}});
|
||||
const userForm = dialog.querySelector("[data-user-share-form]");
|
||||
const linkForm = dialog.querySelector("[data-link-form]");
|
||||
const userList = dialog.querySelector("[data-user-list]");
|
||||
const linkList = dialog.querySelector("[data-link-list]");
|
||||
const syncForever = () => { linkForm.hours.disabled = linkForm.forever.checked; };
|
||||
linkForm.forever.addEventListener("change", syncForever); syncForever();
|
||||
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 => {
|
||||
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");}});
|
||||
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 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"); }
|
||||
try { await refresh(); } catch (err) { setDialogMessage(err.message, "error"); }
|
||||
});
|
||||
|
||||
row.querySelector("[data-password]")?.addEventListener("click", () => {
|
||||
@@ -192,9 +198,9 @@ async function loadResources() {
|
||||
submit.disabled = true;
|
||||
setInlineMessage("");
|
||||
try {
|
||||
await api("/api/auth/resources", { method:"PUT", headers:authHeaders(), body:JSON.stringify({kind:item.kind, slug:item.slug, password}) });
|
||||
await api("/api/auth/resources", { method: "PUT", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, password }) });
|
||||
await loadResources();
|
||||
} catch(e) {
|
||||
} catch (e) {
|
||||
setInlineMessage(e.message, "error");
|
||||
submit.disabled = false;
|
||||
}
|
||||
@@ -210,9 +216,9 @@ async function loadResources() {
|
||||
event.currentTarget.disabled = true;
|
||||
setInlineMessage("");
|
||||
try {
|
||||
await api("/api/auth/resources", { method:"DELETE", headers:authHeaders(), body:JSON.stringify({kind:item.kind, slug:item.slug}) });
|
||||
await api("/api/auth/resources", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug }) });
|
||||
await loadResources();
|
||||
} catch(e) {
|
||||
} catch (e) {
|
||||
setInlineMessage(e.message, "error");
|
||||
event.currentTarget.disabled = false;
|
||||
}
|
||||
@@ -220,7 +226,7 @@ async function loadResources() {
|
||||
});
|
||||
resourcesList.append(row);
|
||||
}
|
||||
} catch(e) { resourcesList.innerHTML=""; resourcesError.textContent=e.message; }
|
||||
} catch (e) { resourcesList.innerHTML = ""; resourcesError.textContent = e.message; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
+51
-51
@@ -1,16 +1,16 @@
|
||||
function clamp(value,min,max){return Math.min(max,Math.max(min,value));}
|
||||
function stem(name){return name.replace(/\.[^.]+$/,"")||"image";}
|
||||
function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); }
|
||||
function stem(name) { return name.replace(/\.[^.]+$/, "") || "image"; }
|
||||
|
||||
export async function prepareImageFile(file){
|
||||
if(!file.type.startsWith("image/"))return file;
|
||||
const url=URL.createObjectURL(file);
|
||||
const image=new Image();
|
||||
image.src=url;
|
||||
export async function prepareImageFile(file) {
|
||||
if (!file.type.startsWith("image/")) return file;
|
||||
const url = URL.createObjectURL(file);
|
||||
const image = new Image();
|
||||
image.src = url;
|
||||
await image.decode();
|
||||
|
||||
const dialog=document.createElement("dialog");
|
||||
dialog.className="image-editor-dialog";
|
||||
dialog.innerHTML=`<form method="dialog" class="image-editor-panel">
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.className = "image-editor-dialog";
|
||||
dialog.innerHTML = `<form method="dialog" class="image-editor-panel">
|
||||
<div class="image-editor-head"><div><h2>Adjust image</h2><p>Keep the whole image or choose a crop, then select output size.</p></div><button class="icon-button" value="cancel" aria-label="Close">×</button></div>
|
||||
<div class="image-crop-stage"><canvas></canvas></div>
|
||||
<div class="image-editor-controls">
|
||||
@@ -21,54 +21,54 @@ export async function prepareImageFile(file){
|
||||
<div class="image-editor-actions"><button class="secondary-button" value="cancel">Cancel</button><button type="button" class="primary-button" data-apply>Use image</button></div>
|
||||
</form>`;
|
||||
document.body.append(dialog);
|
||||
const canvas=dialog.querySelector("canvas"),ctx=canvas.getContext("2d"),stage=dialog.querySelector(".image-crop-stage"),zoomInput=dialog.querySelector("[data-zoom]"),aspectSelect=dialog.querySelector("[data-aspect]"),sizeSelect=dialog.querySelector("[data-size]");
|
||||
let offsetX=0,offsetY=0,dragging=false,lastX=0,lastY=0,accepted=false;
|
||||
const canvas = dialog.querySelector("canvas"), ctx = canvas.getContext("2d"), stage = dialog.querySelector(".image-crop-stage"), zoomInput = dialog.querySelector("[data-zoom]"), aspectSelect = dialog.querySelector("[data-aspect]"), sizeSelect = dialog.querySelector("[data-size]");
|
||||
let offsetX = 0, offsetY = 0, dragging = false, lastX = 0, lastY = 0, accepted = false;
|
||||
|
||||
function cropBox(){
|
||||
const rect=stage.getBoundingClientRect();
|
||||
let width=Math.max(280,rect.width),height=Math.min(520,Math.max(260,rect.height));
|
||||
const aspect=aspectSelect.value==="original"?image.naturalWidth/image.naturalHeight:aspectSelect.value==="free"?width/height:Number(aspectSelect.value);
|
||||
if(width/height>aspect)width=height*aspect;else height=width/aspect;
|
||||
return {width:Math.round(width),height:Math.round(height)};
|
||||
function cropBox() {
|
||||
const rect = stage.getBoundingClientRect();
|
||||
let width = Math.max(280, rect.width), height = Math.min(520, Math.max(260, rect.height));
|
||||
const aspect = aspectSelect.value === "original" ? image.naturalWidth / image.naturalHeight : aspectSelect.value === "free" ? width / height : Number(aspectSelect.value);
|
||||
if (width / height > aspect) width = height * aspect; else height = width / aspect;
|
||||
return { width: Math.round(width), height: Math.round(height) };
|
||||
}
|
||||
function draw(){
|
||||
const box=cropBox(),dpr=Math.min(devicePixelRatio||1,2);
|
||||
canvas.width=Math.round(box.width*dpr);canvas.height=Math.round(box.height*dpr);canvas.style.width=`${box.width}px`;canvas.style.height=`${box.height}px`;
|
||||
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,box.width,box.height);
|
||||
const wholeImage=aspectSelect.value==="original";
|
||||
zoomInput.disabled=wholeImage;
|
||||
const base=wholeImage?Math.min(box.width/image.naturalWidth,box.height/image.naturalHeight):Math.max(box.width/image.naturalWidth,box.height/image.naturalHeight),scale=base*(wholeImage?1:Number(zoomInput.value));
|
||||
const drawW=image.naturalWidth*scale,drawH=image.naturalHeight*scale;
|
||||
const maxX=Math.max(0,(drawW-box.width)/2),maxY=Math.max(0,(drawH-box.height)/2);
|
||||
offsetX=clamp(offsetX,-maxX,maxX);offsetY=clamp(offsetY,-maxY,maxY);
|
||||
ctx.drawImage(image,(box.width-drawW)/2+offsetX,(box.height-drawH)/2+offsetY,drawW,drawH);
|
||||
function draw() {
|
||||
const box = cropBox(), dpr = Math.min(devicePixelRatio || 1, 2);
|
||||
canvas.width = Math.round(box.width * dpr); canvas.height = Math.round(box.height * dpr); canvas.style.width = `${box.width}px`; canvas.style.height = `${box.height}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, box.width, box.height);
|
||||
const wholeImage = aspectSelect.value === "original";
|
||||
zoomInput.disabled = wholeImage;
|
||||
const base = wholeImage ? Math.min(box.width / image.naturalWidth, box.height / image.naturalHeight) : Math.max(box.width / image.naturalWidth, box.height / image.naturalHeight), scale = base * (wholeImage ? 1 : Number(zoomInput.value));
|
||||
const drawW = image.naturalWidth * scale, drawH = image.naturalHeight * scale;
|
||||
const maxX = Math.max(0, (drawW - box.width) / 2), maxY = Math.max(0, (drawH - box.height) / 2);
|
||||
offsetX = clamp(offsetX, -maxX, maxX); offsetY = clamp(offsetY, -maxY, maxY);
|
||||
ctx.drawImage(image, (box.width - drawW) / 2 + offsetX, (box.height - drawH) / 2 + offsetY, drawW, drawH);
|
||||
}
|
||||
function point(event){const p=event.touches?.[0]||event;return {x:p.clientX,y:p.clientY};}
|
||||
canvas.addEventListener("pointerdown",event=>{dragging=true;canvas.setPointerCapture(event.pointerId);({x:lastX,y:lastY}=point(event));});
|
||||
canvas.addEventListener("pointermove",event=>{if(!dragging)return;const p=point(event);offsetX+=p.x-lastX;offsetY+=p.y-lastY;lastX=p.x;lastY=p.y;draw();});
|
||||
canvas.addEventListener("pointerup",()=>dragging=false);canvas.addEventListener("pointercancel",()=>dragging=false);
|
||||
zoomInput.addEventListener("input",draw);aspectSelect.addEventListener("change",()=>{offsetX=0;offsetY=0;draw();});window.addEventListener("resize",draw,{signal:(()=>{const c=new AbortController();dialog.addEventListener("close",()=>c.abort(),{once:true});return c.signal;})()});
|
||||
function point(event) { const p = event.touches?.[0] || event; return { x: p.clientX, y: p.clientY }; }
|
||||
canvas.addEventListener("pointerdown", event => { dragging = true; canvas.setPointerCapture(event.pointerId); ({ x: lastX, y: lastY } = point(event)); });
|
||||
canvas.addEventListener("pointermove", event => { if (!dragging) return; const p = point(event); offsetX += p.x - lastX; offsetY += p.y - lastY; lastX = p.x; lastY = p.y; draw(); });
|
||||
canvas.addEventListener("pointerup", () => dragging = false); canvas.addEventListener("pointercancel", () => dragging = false);
|
||||
zoomInput.addEventListener("input", draw); aspectSelect.addEventListener("change", () => { offsetX = 0; offsetY = 0; draw(); }); window.addEventListener("resize", draw, { signal: (() => { const c = new AbortController(); dialog.addEventListener("close", () => c.abort(), { once: true }); return c.signal; })() });
|
||||
|
||||
const result=new Promise(resolve=>{
|
||||
dialog.addEventListener("close",()=>{URL.revokeObjectURL(url);dialog.remove();resolve(accepted);},{once:true});
|
||||
dialog.querySelector("[data-apply]").addEventListener("click",async()=>{
|
||||
const box=cropBox(),maxSize=Number(sizeSelect.value),out=document.createElement("canvas");
|
||||
if(aspectSelect.value==="original"){
|
||||
const ratio=Math.min(1,maxSize?maxSize/Math.max(image.naturalWidth,image.naturalHeight):1);
|
||||
out.width=Math.max(1,Math.round(image.naturalWidth*ratio));out.height=Math.max(1,Math.round(image.naturalHeight*ratio));
|
||||
out.getContext("2d").drawImage(image,0,0,out.width,out.height);
|
||||
}else{
|
||||
const ratio=Math.min(1,maxSize?maxSize/Math.max(box.width,box.height):1);
|
||||
out.width=Math.max(1,Math.round(box.width*ratio));out.height=Math.max(1,Math.round(box.height*ratio));
|
||||
out.getContext("2d").drawImage(canvas,0,0,out.width,out.height);
|
||||
const result = new Promise(resolve => {
|
||||
dialog.addEventListener("close", () => { URL.revokeObjectURL(url); dialog.remove(); resolve(accepted); }, { once: true });
|
||||
dialog.querySelector("[data-apply]").addEventListener("click", async () => {
|
||||
const box = cropBox(), maxSize = Number(sizeSelect.value), out = document.createElement("canvas");
|
||||
if (aspectSelect.value === "original") {
|
||||
const ratio = Math.min(1, maxSize ? maxSize / Math.max(image.naturalWidth, image.naturalHeight) : 1);
|
||||
out.width = Math.max(1, Math.round(image.naturalWidth * ratio)); out.height = Math.max(1, Math.round(image.naturalHeight * ratio));
|
||||
out.getContext("2d").drawImage(image, 0, 0, out.width, out.height);
|
||||
} else {
|
||||
const ratio = Math.min(1, maxSize ? maxSize / Math.max(box.width, box.height) : 1);
|
||||
out.width = Math.max(1, Math.round(box.width * ratio)); out.height = Math.max(1, Math.round(box.height * ratio));
|
||||
out.getContext("2d").drawImage(canvas, 0, 0, out.width, out.height);
|
||||
}
|
||||
const mime=file.type==="image/png"?"image/png":"image/jpeg";
|
||||
const blob=await new Promise(r=>out.toBlob(r,mime,mime==="image/jpeg"?.88:undefined));
|
||||
const ext=mime==="image/png"?"png":"jpg";
|
||||
accepted=new File([blob],`${stem(file.name)}-edited.${ext}`,{type:mime,lastModified:Date.now()});
|
||||
const mime = file.type === "image/png" ? "image/png" : "image/jpeg";
|
||||
const blob = await new Promise(r => out.toBlob(r, mime, mime === "image/jpeg" ? .88 : undefined));
|
||||
const ext = mime === "image/png" ? "png" : "jpg";
|
||||
accepted = new File([blob], `${stem(file.name)}-edited.${ext}`, { type: mime, lastModified: Date.now() });
|
||||
dialog.close();
|
||||
});
|
||||
});
|
||||
dialog.showModal();requestAnimationFrame(draw);
|
||||
dialog.showModal(); requestAnimationFrame(draw);
|
||||
return result;
|
||||
}
|
||||
|
||||
+7
-11
@@ -1,5 +1,7 @@
|
||||
import { EMOJI_SHORTCODES } from "@rustpad/emoji-data";
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
return String(value).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
|
||||
function safeUrl(value) {
|
||||
@@ -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 = [];
|
||||
@@ -146,7 +142,7 @@ function renderList(lines, start, lineOffset = 0, baseIndent = null, forcedType
|
||||
}
|
||||
|
||||
const classAttr = hasTask ? ` class="contains-task-items"` : "";
|
||||
return {html: `<${type}${classAttr}>${body}</${type}>`, end: index};
|
||||
return { html: `<${type}${classAttr}>${body}</${type}>`, end: index };
|
||||
}
|
||||
|
||||
function splitTableRow(line) {
|
||||
@@ -190,7 +186,7 @@ function collectHeadings(lines) {
|
||||
const count = used.get(base) || 0;
|
||||
used.set(base, count + 1);
|
||||
const id = count ? `${base}-${count + 1}` : base;
|
||||
headings.push({level: match[1].length, text: match[2], id, index});
|
||||
headings.push({ level: match[1].length, text: match[2], id, index });
|
||||
});
|
||||
return headings;
|
||||
}
|
||||
@@ -228,7 +224,7 @@ export function renderMarkdown(source, lineOffset = 0) {
|
||||
lines[i] = "";
|
||||
}
|
||||
|
||||
const closeList = () => {};
|
||||
const closeList = () => { };
|
||||
const closeCode = () => {
|
||||
const body = escapeHtml(code.join("\n"));
|
||||
const lang = normalizeLanguage(language);
|
||||
|
||||
+174
-173
@@ -1,213 +1,214 @@
|
||||
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];
|
||||
const editor=document.querySelector("#editor"), preview=document.querySelector("#preview"), editorWorkspace=document.querySelector("#editor-workspace"), gutter=document.querySelector("#line-gutter"), ownerLabels=document.querySelector("#owner-labels");
|
||||
const modeToggle=document.querySelector("#mode-toggle"), passwordDialog=document.querySelector("#password-dialog"), identityDialog=document.querySelector("#identity-dialog");
|
||||
const roomDetails=document.querySelector("#room-details"), roomUsers=document.querySelector("#room-users"), roomCount=document.querySelector("#room-count"), socketLatency=document.querySelector("#socket-latency"), chatMessages=document.querySelector("#chat-messages"), chatForm=document.querySelector("#chat-form"), chatInput=document.querySelector("#chat-input"), chatUnread=document.querySelector("#chat-unread");
|
||||
let unreadChat=0;
|
||||
const compactToggle=document.querySelector("#compact-toggle"), publicTaskUpdates=document.querySelector("#public-task-updates"), fontFamily=document.querySelector("#font-family"), fontSize=document.querySelector("#font-size"), currentUser=document.querySelector("#current-user"), userColorPicker=document.querySelector("#user-color-picker");
|
||||
const shareToken=new URLSearchParams(location.search).get("share");if(shareToken)setAccessToken("workspace",workspaceSlug,shareToken);
|
||||
let accessToken=shareToken||getAuthToken()||getAccessToken("workspace",workspaceSlug), password="", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[];
|
||||
const lineToggle=document.querySelector("#line-numbers-toggle"), previewLineToggle=document.querySelector("#preview-line-numbers-toggle");
|
||||
lineToggle.checked=localStorage.getItem("rustpad:line-numbers")!=="off";
|
||||
previewLineToggle.checked=localStorage.getItem("rustpad:preview-line-numbers")==="on";
|
||||
compactToggle.checked=localStorage.getItem("rustpad:compact")!=="off";
|
||||
fontFamily.value=localStorage.getItem("rustpad:font-family")||"mono";
|
||||
fontSize.value=localStorage.getItem("rustpad:font-size")||"14";
|
||||
function defaultColorFor(name){let h=0;for(const c of name||"?")h=(h*31+c.charCodeAt(0))%360;return `hsl(${h} 70% 62%)`;}
|
||||
function storedColorKey(name){return `rustpad:user-color:${encodeURIComponent(name||"")}`;}
|
||||
function ownerParts(owner){const raw=String(owner||"");const split=raw.lastIndexOf("\u001f");return split<0?{name:raw,color:""}:{name:raw.slice(0,split),color:raw.slice(split+1)};}
|
||||
function ownerName(owner){return ownerParts(owner).name;}
|
||||
function colorFor(owner){const parts=ownerParts(owner);return /^#[0-9a-f]{6}$/i.test(parts.color)?parts.color:defaultColorFor(parts.name);}
|
||||
function currentUserColor(){return localStorage.getItem(storedColorKey(nickname))||"";}
|
||||
function currentOwner(){const color=currentUserColor();return color?`${nickname}\u001f${color}`:nickname;}
|
||||
function updateCurrentUser(){const color=currentUserColor()||defaultColorFor(nickname);currentUser.querySelector(".user-chip__name").textContent=nickname;currentUser.style.setProperty("--owner",color);userColorPicker.value=/^#[0-9a-f]{6}$/i.test(color)?color:"#7c6cff";}
|
||||
function toast(text){const el=document.querySelector("#toast");el.textContent=text;el.classList.add("visible");setTimeout(()=>el.classList.remove("visible"),1800);}
|
||||
function updatePresence(users){const entries=Array.isArray(users)?users:[];roomCount.textContent=`${entries.length} ${entries.length===1?"user":"users"}`;roomUsers.replaceChildren(...entries.map(entry=>{const user=typeof entry==="string"?{name:entry,color:""}:entry||{};const li=document.createElement("li"),dot=document.createElement("span"),label=document.createElement("span");li.className="room-user";dot.className="room-user__dot";dot.style.setProperty("--owner",/^#[0-9a-f]{6}$/i.test(user.color||"")?user.color:defaultColorFor(user.name));label.textContent=user.name||"Guest";li.title=label.textContent;li.append(dot,label);return li;}));if(!entries.length){const li=document.createElement("li");li.textContent="No active users";roomUsers.append(li);}}
|
||||
function updateLatency(ms){socketLatency.textContent=Number.isFinite(ms)?`${ms} ms`:"— ms";}
|
||||
function appendLinkifiedText(container,value){const text=String(value||"");const urlPattern=/https?:\/\/[^\s<>{}\[\]"'`]+/gi;let index=0;for(const match of text.matchAll(urlPattern)){const start=match.index??0;if(start>index)container.append(document.createTextNode(text.slice(index,start)));let raw=match[0],trail="";while(/[),.!?:;]$/.test(raw)){trail=raw.slice(-1)+trail;raw=raw.slice(0,-1);}try{const url=new URL(raw);if(url.protocol==="http:"||url.protocol==="https:"){const link=document.createElement("a");link.href=url.href;link.textContent=raw;link.target="_blank";link.rel="noopener noreferrer";container.append(link);}else container.append(document.createTextNode(raw));}catch{container.append(document.createTextNode(raw));}if(trail)container.append(document.createTextNode(trail));index=start+match[0].length;}if(index<text.length)container.append(document.createTextNode(text.slice(index)));}
|
||||
function appendChatMessage(message){const empty=chatMessages.querySelector(".chat-empty");empty?.remove();const row=document.createElement("p");row.className="chat-message";const author=document.createElement("strong");author.textContent=message.sender;const text=document.createElement("span");appendLinkifiedText(text,message.text);row.append(author,text);chatMessages.append(row);while(chatMessages.children.length>100)chatMessages.firstElementChild.remove();chatMessages.scrollTop=chatMessages.scrollHeight;if(message.sender!==nickname&&!roomDetails.open){unreadChat++;chatUnread.hidden=false;chatUnread.textContent=unreadChat>99?"99+":String(unreadChat);const oldTitle=document.title;if(!document.title.startsWith("● "))document.title=`● ${oldTitle}`;if(document.hidden&&Notification.permission==="granted")new Notification(`${message.sender} wrote in RustPad`,{body:message.text.slice(0,160),tag:"rustpad-room-chat"});}}
|
||||
function clearUnread(){unreadChat=0;chatUnread.hidden=true;chatUnread.textContent="";document.title=document.title.replace(/^● /,"");}
|
||||
const parts = location.pathname.split("/").filter(Boolean), workspaceSlug = parts[1], noteSlug = parts[3];
|
||||
const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels");
|
||||
const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
|
||||
const roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread");
|
||||
let unreadChat = 0;
|
||||
const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker");
|
||||
const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken("workspace", workspaceSlug, shareToken);
|
||||
let accessToken = shareToken || getAuthToken() || getAccessToken("workspace", workspaceSlug), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState(), owners = [];
|
||||
const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle");
|
||||
lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off";
|
||||
previewLineToggle.checked = localStorage.getItem("rustpad:preview-line-numbers") === "on";
|
||||
compactToggle.checked = localStorage.getItem("rustpad:compact") !== "off";
|
||||
fontFamily.value = localStorage.getItem("rustpad:font-family") || "mono";
|
||||
fontSize.value = localStorage.getItem("rustpad:font-size") || "14";
|
||||
function defaultColorFor(name) { let h = 0; for (const c of name || "?") h = (h * 31 + c.charCodeAt(0)) % 360; return `hsl(${h} 70% 62%)`; }
|
||||
function storedColorKey(name) { return `rustpad:user-color:${encodeURIComponent(name || "")}`; }
|
||||
function ownerParts(owner) { const raw = String(owner || ""); const split = raw.lastIndexOf("\u001f"); return split < 0 ? { name: raw, color: "" } : { name: raw.slice(0, split), color: raw.slice(split + 1) }; }
|
||||
function ownerName(owner) { return ownerParts(owner).name; }
|
||||
function colorFor(owner) { const parts = ownerParts(owner); return /^#[0-9a-f]{6}$/i.test(parts.color) ? parts.color : defaultColorFor(parts.name); }
|
||||
function currentUserColor() { return localStorage.getItem(storedColorKey(nickname)) || ""; }
|
||||
function currentOwner() { const color = currentUserColor(); return color ? `${nickname}\u001f${color}` : nickname; }
|
||||
function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); userColorPicker.value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; }
|
||||
function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1800); }
|
||||
function updatePresence(users) { const entries = Array.isArray(users) ? users : []; roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...entries.map(entry => { const user = typeof entry === "string" ? { name: entry, color: "" } : entry || {}; const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } }
|
||||
function updateLatency(ms) { socketLatency.textContent = Number.isFinite(ms) ? `${ms} ms` : "— ms"; }
|
||||
function appendLinkifiedText(container, value) { const text = String(value || ""); const urlPattern = /https?:\/\/[^\s<>{}\[\]"'`]+/gi; let index = 0; for (const match of text.matchAll(urlPattern)) { const start = match.index ?? 0; if (start > index) container.append(document.createTextNode(text.slice(index, start))); let raw = match[0], trail = ""; while (/[),.!?:;]$/.test(raw)) { trail = raw.slice(-1) + trail; raw = raw.slice(0, -1); } try { const url = new URL(raw); if (url.protocol === "http:" || url.protocol === "https:") { const link = document.createElement("a"); link.href = url.href; link.textContent = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; container.append(link); } else container.append(document.createTextNode(raw)); } catch { container.append(document.createTextNode(raw)); } if (trail) container.append(document.createTextNode(trail)); index = start + match[0].length; } if (index < text.length) container.append(document.createTextNode(text.slice(index))); }
|
||||
function appendChatMessage(message) { const empty = chatMessages.querySelector(".chat-empty"); empty?.remove(); const row = document.createElement("p"); row.className = "chat-message"; const author = document.createElement("strong"); author.textContent = message.sender; const text = document.createElement("span"); appendLinkifiedText(text, message.text); row.append(author, text); chatMessages.append(row); while (chatMessages.children.length > 100) chatMessages.firstElementChild.remove(); chatMessages.scrollTop = chatMessages.scrollHeight; if (message.sender !== nickname && !roomDetails.open) { unreadChat++; chatUnread.hidden = false; chatUnread.textContent = unreadChat > 99 ? "99+" : String(unreadChat); const oldTitle = document.title; if (!document.title.startsWith("● ")) document.title = `● ${oldTitle}`; if (document.hidden && Notification.permission === "granted") new Notification(`${message.sender} wrote in RustPad`, { body: message.text.slice(0, 160), tag: "rustpad-room-chat" }); } }
|
||||
function clearUnread() { unreadChat = 0; chatUnread.hidden = true; chatUnread.textContent = ""; document.title = document.title.replace(/^● /, ""); }
|
||||
|
||||
function setStatus(kind,text){document.querySelector("#status-dot").className=`status__dot${kind?` is-${kind}`:""}`;document.querySelector("#status-text").textContent=text;}
|
||||
function updateAddressLabel(){document.querySelector("#note-url").textContent=`${location.pathname}${location.search}`;}
|
||||
async function renderMermaid(){const nodes=preview.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'<p class="error">Failed to load Mermaid.</p>'));}}
|
||||
async function renderCodeHighlight(){const nodes=preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)');if(!nodes.length)return;try{const hljs=await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm");nodes.forEach(node=>{const lines=node.querySelectorAll(".code-line");if(!lines.length){hljs.default.highlightElement(node);return;}const language=[...node.classList].find(name=>name.startsWith("language-"))?.slice(9);lines.forEach(line=>{try{line.innerHTML=hljs.default.highlight(line.textContent,{language,ignoreIllegals:true}).value;}catch{line.innerHTML=hljs.default.highlightAuto(line.textContent).value;}});node.classList.add("hljs");});}catch{}}
|
||||
function renderGutter(){
|
||||
const lineCount=Math.max(1,(editor.value.match(/\n/g)||[]).length+1);
|
||||
const lines=Array.from({length:lineCount});
|
||||
owners=owners.slice(0,lineCount);
|
||||
while(owners.length<lineCount)owners.push(owners.at(-1)||currentOwner()||"");
|
||||
const style=getComputedStyle(editor), lineHeight=parseFloat(style.lineHeight)||29, paddingTop=parseFloat(style.paddingTop)||24, paddingBottom=parseFloat(style.paddingBottom)||24;
|
||||
gutter.style.paddingTop=`${paddingTop}px`;gutter.style.paddingBottom=`${paddingBottom}px`;gutter.style.lineHeight=`${lineHeight}px`;
|
||||
gutter.innerHTML=lines.map((_,i)=>`<div style="height:${lineHeight}px">${i+1}</div>`).join("");
|
||||
ownerLabels.style.setProperty("--editor-line-height",`${lineHeight}px`);
|
||||
ownerLabels.innerHTML=lines.map((_,i)=>{
|
||||
const owner=owners[i]||"";
|
||||
if(!owner)return "";
|
||||
const top=paddingTop+i*lineHeight-editor.scrollTop;
|
||||
const label=owner!==owners[i-1]?`<span class="owner-label" style="top:${top}px;--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`:"";
|
||||
function setStatus(kind, text) { document.querySelector("#status-dot").className = `status__dot${kind ? ` is-${kind}` : ""}`; document.querySelector("#status-text").textContent = text; }
|
||||
function updateAddressLabel() { document.querySelector("#note-url").textContent = `${location.pathname}${location.search}`; }
|
||||
async function renderMermaid() { const nodes = preview.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
|
||||
async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } }
|
||||
function renderGutter() {
|
||||
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
|
||||
const lines = Array.from({ length: lineCount });
|
||||
owners = owners.slice(0, lineCount);
|
||||
while (owners.length < lineCount) owners.push(owners.at(-1) || currentOwner() || "");
|
||||
const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, paddingTop = parseFloat(style.paddingTop) || 24, paddingBottom = parseFloat(style.paddingBottom) || 24;
|
||||
gutter.style.paddingTop = `${paddingTop}px`; gutter.style.paddingBottom = `${paddingBottom}px`; gutter.style.lineHeight = `${lineHeight}px`;
|
||||
gutter.innerHTML = lines.map((_, i) => `<div style="height:${lineHeight}px">${i + 1}</div>`).join("");
|
||||
ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
|
||||
ownerLabels.innerHTML = lines.map((_, i) => {
|
||||
const owner = owners[i] || "";
|
||||
if (!owner) return "";
|
||||
const top = paddingTop + i * lineHeight - editor.scrollTop;
|
||||
const label = owner !== owners[i - 1] ? `<span class="owner-label" style="top:${top}px;--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>` : "";
|
||||
return `<span class="owner-line" style="top:${top}px;--owner:${colorFor(owner)}"></span>${label}`;
|
||||
}).join("");
|
||||
document.body.classList.toggle("hide-editor-line-numbers",!lineToggle.checked);
|
||||
document.body.classList.toggle("hide-preview-line-numbers",!previewLineToggle.checked);
|
||||
document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked);
|
||||
document.body.classList.toggle("hide-preview-line-numbers", !previewLineToggle.checked);
|
||||
}
|
||||
function escapeHtml(v){return String(v).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));}function formatDate(value){const raw=String(value??"").trim();let normalized=raw;if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized))normalized=normalized.replace(" ","T")+":00";else if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized))normalized=normalized.replace(" ","T");else if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized))normalized=normalized.replace(" ","T")+"Z";const date=new Date(normalized);return Number.isNaN(date.getTime())?raw:date.toLocaleString("pl-PL",{year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"});}
|
||||
function escapeHtml(v) { return String(v).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } function formatDate(value) { const raw = String(value ?? "").trim(); let normalized = raw; if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T") + ":00"; else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T"); else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized)) normalized = normalized.replace(" ", "T") + "Z"; const date = new Date(normalized); return Number.isNaN(date.getTime()) ? raw : date.toLocaleString("pl-PL", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); }
|
||||
|
||||
function markdownFromPreview(node){
|
||||
const walk=current=>{
|
||||
if(current.nodeType===Node.TEXT_NODE)return current.nodeValue||"";
|
||||
if(current.nodeType!==Node.ELEMENT_NODE)return "";
|
||||
const tag=current.tagName.toLowerCase(),body=[...current.childNodes].map(walk).join("");
|
||||
if(tag==="strong"||tag==="b")return `**${body}**`;
|
||||
if(tag==="em"||tag==="i")return `*${body}*`;
|
||||
if(tag==="s"||tag==="del")return `~~${body}~~`;
|
||||
if(tag==="mark")return `==${body}==`;
|
||||
if(tag==="code")return "`"+body+"`";
|
||||
if(tag==="sub")return `~${body}~`;
|
||||
if(tag==="sup"&&!current.classList.contains("footnote-ref"))return `^${body}^`;
|
||||
if(tag==="a")return `[${body}](${current.getAttribute("href")||"#"})`;
|
||||
if(tag==="img"){
|
||||
const src=current.getAttribute("src")||"";
|
||||
const alt=current.getAttribute("alt")||"";
|
||||
const title=current.getAttribute("title");
|
||||
return `}"`:""})`;
|
||||
function markdownFromPreview(node) {
|
||||
const walk = current => {
|
||||
if (current.nodeType === Node.TEXT_NODE) return current.nodeValue || "";
|
||||
if (current.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
const tag = current.tagName.toLowerCase(), body = [...current.childNodes].map(walk).join("");
|
||||
if (tag === "strong" || tag === "b") return `**${body}**`;
|
||||
if (tag === "em" || tag === "i") return `*${body}*`;
|
||||
if (tag === "s" || tag === "del") return `~~${body}~~`;
|
||||
if (tag === "mark") return `==${body}==`;
|
||||
if (tag === "code") return "`" + body + "`";
|
||||
if (tag === "sub") return `~${body}~`;
|
||||
if (tag === "sup" && !current.classList.contains("footnote-ref")) return `^${body}^`;
|
||||
if (tag === "a") return `[${body}](${current.getAttribute("href") || "#"})`;
|
||||
if (tag === "img") {
|
||||
const src = current.getAttribute("src") || "";
|
||||
const alt = current.getAttribute("alt") || "";
|
||||
const title = current.getAttribute("title");
|
||||
return `}"` : ""})`;
|
||||
}
|
||||
if(tag==="br")return " ";
|
||||
if (tag === "br") return " ";
|
||||
return body;
|
||||
};
|
||||
return [...node.childNodes].map(walk).join("").replace(/\n/g," ").trim();
|
||||
return [...node.childNodes].map(walk).join("").replace(/\n/g, " ").trim();
|
||||
}
|
||||
|
||||
function previewCaretOffset(target){
|
||||
const selection=window.getSelection();
|
||||
if(!selection?.rangeCount)return 0;
|
||||
const range=selection.getRangeAt(0);
|
||||
if(!target.contains(range.startContainer))return 0;
|
||||
const prefix=range.cloneRange();
|
||||
function previewCaretOffset(target) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) return 0;
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!target.contains(range.startContainer)) return 0;
|
||||
const prefix = range.cloneRange();
|
||||
prefix.selectNodeContents(target);
|
||||
prefix.setEnd(range.startContainer,range.startOffset);
|
||||
prefix.setEnd(range.startContainer, range.startOffset);
|
||||
return prefix.toString().length;
|
||||
}
|
||||
function placePreviewCaret(target,offset){
|
||||
const walker=document.createTreeWalker(target,NodeFilter.SHOW_TEXT);
|
||||
let remaining=Math.max(0,offset),node;
|
||||
while((node=walker.nextNode())){
|
||||
if(remaining<=node.nodeValue.length){
|
||||
const range=document.createRange();range.setStart(node,remaining);range.collapse(true);
|
||||
const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);return;
|
||||
function placePreviewCaret(target, offset) {
|
||||
const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT);
|
||||
let remaining = Math.max(0, offset), node;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (remaining <= node.nodeValue.length) {
|
||||
const range = document.createRange(); range.setStart(node, remaining); range.collapse(true);
|
||||
const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); return;
|
||||
}
|
||||
remaining-=node.nodeValue.length;
|
||||
remaining -= node.nodeValue.length;
|
||||
}
|
||||
const range=document.createRange();range.selectNodeContents(target);range.collapse(false);
|
||||
const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);
|
||||
const range = document.createRange(); range.selectNodeContents(target); range.collapse(false);
|
||||
const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range);
|
||||
}
|
||||
function movePreviewCaret(target,direction){
|
||||
const editables=[...preview.querySelectorAll(".preview-editable")];
|
||||
const index=editables.indexOf(target),next=editables[index+direction];
|
||||
if(!next)return false;
|
||||
const offset=previewCaretOffset(target);next.focus();placePreviewCaret(next,offset);next.scrollIntoView({block:"nearest"});return true;
|
||||
function movePreviewCaret(target, direction) {
|
||||
const editables = [...preview.querySelectorAll(".preview-editable")];
|
||||
const index = editables.indexOf(target), next = editables[index + direction];
|
||||
if (!next) return false;
|
||||
const offset = previewCaretOffset(target); next.focus(); placePreviewCaret(next, offset); next.scrollIntoView({ block: "nearest" }); return true;
|
||||
}
|
||||
function continueIndentation(event){
|
||||
if(event.key!=="Enter"||event.shiftKey||event.ctrlKey||event.metaKey||event.altKey)return;
|
||||
const start=editor.selectionStart,end=editor.selectionEnd;
|
||||
const lineStart=editor.value.lastIndexOf("\n",start-1)+1;
|
||||
const current=editor.value.slice(lineStart,start);
|
||||
const indent=(current.match(/^[ \t]*/)||[""])[0];
|
||||
if(!indent)return;
|
||||
function continueIndentation(event) {
|
||||
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
const start = editor.selectionStart, end = editor.selectionEnd;
|
||||
const lineStart = editor.value.lastIndexOf("\n", start - 1) + 1;
|
||||
const current = editor.value.slice(lineStart, start);
|
||||
const indent = (current.match(/^[ \t]*/) || [""])[0];
|
||||
if (!indent) return;
|
||||
event.preventDefault();
|
||||
editor.setRangeText(`\n${indent}`,start,end,"end");
|
||||
editor.dispatchEvent(new Event("input",{bubbles:true}));
|
||||
editor.setRangeText(`\n${indent}`, start, end, "end");
|
||||
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function replaceTableCell(line,index,value){
|
||||
const leading=line.trimStart().startsWith("|"),trailing=line.trimEnd().endsWith("|");
|
||||
let body=line.trim();if(leading)body=body.slice(1);if(trailing)body=body.slice(0,-1);
|
||||
const cells=body.split("|").map(cell=>cell.trim());while(cells.length<=index)cells.push("");cells[index]=value.replace(/\|/g,"|");
|
||||
return `${leading?"| ":""}${cells.join(" | ")}${trailing?" |":""}`;
|
||||
function replaceTableCell(line, index, value) {
|
||||
const leading = line.trimStart().startsWith("|"), trailing = line.trimEnd().endsWith("|");
|
||||
let body = line.trim(); if (leading) body = body.slice(1); if (trailing) body = body.slice(0, -1);
|
||||
const cells = body.split("|").map(cell => cell.trim()); while (cells.length <= index) cells.push(""); cells[index] = value.replace(/\|/g, "|");
|
||||
return `${leading ? "| " : ""}${cells.join(" | ")}${trailing ? " |" : ""}`;
|
||||
}
|
||||
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 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(), 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}={}){
|
||||
try{
|
||||
const files=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,{method:"PUT",body:JSON.stringify({access_token:accessToken||null})});
|
||||
const totalSize=files.reduce((sum,file)=>sum+(Number(file.size_bytes)||0),0);
|
||||
document.querySelector("#footer-files").textContent=`${files.length} ${files.length===1?"file":"files"} · ${formatBytes(totalSize)}`;
|
||||
const list=document.querySelector("#files-list");
|
||||
list.innerHTML=files.length?files.map(file=>`<div class="file-row" data-file-row="${file.id}"><div class="file-row-main"><div class="file-name">${escapeHtml(file.filename)}</div><div class="file-meta">${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)} · <span class="file-flag ${file.is_attached?"":"detached"}">${file.is_attached?"in note":"removed from content"}</span></div></div><div class="file-actions"><button data-show-file-code="link" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Link</button><button data-show-file-code="markdown" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Markdown</button>${info?.can_delete_files?`<button class="file-delete" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>`:""}</div><div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button data-copy-generated>Copy</button></div></div>`).join(""):'<p class="empty">No files uploaded.</p>';
|
||||
if(open&&!document.querySelector("#files-dialog").open)document.querySelector("#files-dialog").showModal();
|
||||
}catch(error){toast(error.message);}
|
||||
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 } = {}) {
|
||||
try {
|
||||
const files = await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`, { method: "PUT", body: JSON.stringify({ access_token: accessToken || null }) });
|
||||
const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0);
|
||||
document.querySelector("#footer-files").textContent = `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`;
|
||||
const list = document.querySelector("#files-list");
|
||||
list.innerHTML = files.length ? files.map(file => `<div class="file-row" data-file-row="${file.id}"><div class="file-row-main"><div class="file-name">${escapeHtml(file.filename)}</div><div class="file-meta">${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)} · <span class="file-flag ${file.is_attached ? "" : "detached"}">${file.is_attached ? "in note" : "removed from content"}</span></div></div><div class="file-actions"><button data-show-file-code="link" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Link</button><button data-show-file-code="markdown" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Markdown</button>${info?.can_delete_files ? `<button class="file-delete" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>` : ""}</div><div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button data-copy-generated>Copy</button></div></div>`).join("") : '<p class="empty">No files uploaded.</p>';
|
||||
if (open && !document.querySelector("#files-dialog").open) document.querySelector("#files-dialog").showModal();
|
||||
} catch (error) { toast(error.message); }
|
||||
}
|
||||
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/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>`;}}
|
||||
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/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});
|
||||
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();});
|
||||
chatForm.addEventListener("submit",event=>{event.preventDefault();const text=chatInput.value.trim();if(!text||!socket)return;socket.chat(text);chatInput.value="";chatInput.focus();});
|
||||
if(!chatMessages.children.length){const empty=document.createElement("p");empty.className="chat-empty";empty.textContent="No messages yet";chatMessages.append(empty);}
|
||||
currentUser.addEventListener("click",()=>userColorPicker.click());
|
||||
userColorPicker.addEventListener("input",()=>{
|
||||
localStorage.setItem(storedColorKey(nickname),userColorPicker.value);
|
||||
const replacement=currentOwner();
|
||||
owners=owners.map(owner=>ownerName(owner)===nickname?replacement:owner);
|
||||
updateCurrentUser();render();
|
||||
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); 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(); });
|
||||
chatForm.addEventListener("submit", event => { event.preventDefault(); const text = chatInput.value.trim(); if (!text || !socket) return; socket.chat(text); chatInput.value = ""; chatInput.focus(); });
|
||||
if (!chatMessages.children.length) { const empty = document.createElement("p"); empty.className = "chat-empty"; empty.textContent = "No messages yet"; chatMessages.append(empty); }
|
||||
currentUser.addEventListener("click", () => userColorPicker.click());
|
||||
userColorPicker.addEventListener("input", () => {
|
||||
localStorage.setItem(storedColorKey(nickname), userColorPicker.value);
|
||||
const replacement = currentOwner();
|
||||
owners = owners.map(owner => ownerName(owner) === nickname ? replacement : owner);
|
||||
updateCurrentUser(); render();
|
||||
socket?.setColor(userColorPicker.value);
|
||||
if(socket)socket.update(editor.value,JSON.stringify(owners));
|
||||
if (socket) socket.update(editor.value, JSON.stringify(owners));
|
||||
});
|
||||
window.addEventListener("storage",event=>{
|
||||
if(event.key!==storedColorKey(nickname))return;
|
||||
const replacement=currentOwner();
|
||||
owners=owners.map(owner=>ownerName(owner)===nickname?replacement:owner);
|
||||
updateCurrentUser();render();
|
||||
socket?.setColor(currentUserColor()||null);
|
||||
if(socket)socket.update(editor.value,JSON.stringify(owners));
|
||||
window.addEventListener("storage", event => {
|
||||
if (event.key !== storedColorKey(nickname)) return;
|
||||
const replacement = currentOwner();
|
||||
owners = owners.map(owner => ownerName(owner) === nickname ? replacement : owner);
|
||||
updateCurrentUser(); render();
|
||||
socket?.setColor(currentUserColor() || null);
|
||||
if (socket) socket.update(editor.value, JSON.stringify(owners));
|
||||
});
|
||||
editor.addEventListener("keydown",continueIndentation);editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.length<newLines)owners.push(currentOwner());owners=owners.slice(0,newLines);owners[cursorLine]=currentOwner();render();if(applyingRemote)return;clearTimeout(saveTimer);document.querySelector("#save-state").textContent="Saving…";saveTimer=setTimeout(()=>socket?.update(editor.value,JSON.stringify(owners)),250);});
|
||||
document.querySelector("#password-form").addEventListener("submit",async e=>{e.preventDefault();try{password=document.querySelector("#open-password").value;const result=await api("/api/access-token",{method:"POST",body:JSON.stringify({kind:"workspace",slug:workspaceSlug,password})});accessToken=result.access_token;setAccessToken("workspace",workspaceSlug,accessToken);password="";document.querySelector("#open-password").value="";document.querySelector("#password-error").textContent="";loadFiles();connect();}catch(error){document.querySelector("#password-error").textContent=error.message;}});
|
||||
const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='<p class="empty">Loading…</p>';try{const revisions=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/history`,{method:"POST",body:JSON.stringify({access_token:accessToken||null})});list.innerHTML=revisions.length?revisions.map((r,i)=>{const snippet=escapeHtml(r.content.trim().split("\n").slice(0,3).join(" · ").slice(0,150)||"Empty note");const author=r.author||"Unknown author";return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`;}).join(""):'<p class="empty">No history yet.</p>';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/restore`,{method:"POST",body:JSON.stringify({access_token:accessToken||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`<p class="error">${escapeHtml(e.message)}</p>`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");});
|
||||
document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{let file=e.target.files[0];if(!file)return;if(file.type.startsWith("image/")){file=await prepareImageFile(file);if(!file){e.target.value="";return;}}const form=new FormData();form.append("access_token",accessToken||"");form.append("file",file);try{const result=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?``:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");loadFiles();}catch(err){toast(err.message);}e.target.value="";});
|
||||
document.querySelector("#files-button").addEventListener("click",()=>loadFiles({open:true}));
|
||||
document.querySelector("#footer-files").addEventListener("click",()=>loadFiles({open:true}));
|
||||
document.querySelector("#close-files").addEventListener("click",()=>document.querySelector("#files-dialog").close());
|
||||
document.querySelector("#files-list").addEventListener("click",async event=>{
|
||||
const showButton=event.target.closest("[data-show-file-code]");
|
||||
if(showButton){
|
||||
const row=showButton.closest(".file-row"), panel=row.querySelector(".file-code"), output=panel.querySelector("textarea");
|
||||
const absolute=new URL(showButton.dataset.url,location.origin).href;
|
||||
let text=absolute;
|
||||
if(showButton.dataset.showFileCode==="markdown")text=showButton.dataset.mime?.startsWith("image/")?``:`[${showButton.dataset.name}](${absolute})`;
|
||||
if(showButton.dataset.showFileCode==="html")text=(showButton.dataset.mime||"").startsWith("image/")?`<img src="${absolute}" alt="${showButton.dataset.name}">`:`<a href="${absolute}">${showButton.dataset.name}</a>`;
|
||||
output.value=text;panel.hidden=false;output.focus();output.select();return;
|
||||
editor.addEventListener("keydown", continueIndentation); editor.addEventListener("scroll", () => { gutter.scrollTop = editor.scrollTop; renderGutter(); }); editor.addEventListener("input", () => { const newLines = editor.value.split("\n").length; const cursorLine = editor.value.slice(0, editor.selectionStart).split("\n").length - 1; while (owners.length < newLines) owners.push(currentOwner()); owners = owners.slice(0, newLines); owners[cursorLine] = currentOwner(); render(); if (applyingRemote) return; clearTimeout(saveTimer); document.querySelector("#save-state").textContent = "Saving…"; saveTimer = setTimeout(() => socket?.update(editor.value, JSON.stringify(owners)), 250); });
|
||||
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "workspace", slug: workspaceSlug, password }) }); accessToken = result.access_token; setAccessToken("workspace", workspaceSlug, accessToken); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; loadFiles(); connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } });
|
||||
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/history`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null }) }); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/restore`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null, revision_id: r.id }) }); toast("Version restored"); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
|
||||
document.querySelector("#upload-button").addEventListener("click", () => document.querySelector("#file-input").click()); document.querySelector("#file-input").addEventListener("change", async e => { let file = e.target.files[0]; if (!file) return; if (file.type.startsWith("image/")) { file = await prepareImageFile(file); if (!file) { e.target.value = ""; return; } } const form = new FormData(); form.append("access_token", accessToken || ""); form.append("file", file); try { const result = await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`, { method: "POST", body: form, headers: {} }); const image = file.type.startsWith("image/"); const text = image ? `` : `[${file.name}](${result.url})`; editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end"); editor.dispatchEvent(new Event("input")); toast("File uploaded"); loadFiles(); } catch (err) { toast(err.message); } e.target.value = ""; });
|
||||
document.querySelector("#files-button").addEventListener("click", () => loadFiles({ open: true }));
|
||||
document.querySelector("#footer-files").addEventListener("click", () => loadFiles({ open: true }));
|
||||
document.querySelector("#close-files").addEventListener("click", () => document.querySelector("#files-dialog").close());
|
||||
document.querySelector("#files-list").addEventListener("click", async event => {
|
||||
const showButton = event.target.closest("[data-show-file-code]");
|
||||
if (showButton) {
|
||||
const row = showButton.closest(".file-row"), panel = row.querySelector(".file-code"), output = panel.querySelector("textarea");
|
||||
const absolute = new URL(showButton.dataset.url, location.origin).href;
|
||||
let text = absolute;
|
||||
if (showButton.dataset.showFileCode === "markdown") text = showButton.dataset.mime?.startsWith("image/") ? `` : `[${showButton.dataset.name}](${absolute})`;
|
||||
if (showButton.dataset.showFileCode === "html") text = (showButton.dataset.mime || "").startsWith("image/") ? `<img src="${absolute}" alt="${showButton.dataset.name}">` : `<a href="${absolute}">${showButton.dataset.name}</a>`;
|
||||
output.value = text; panel.hidden = false; output.focus(); output.select(); return;
|
||||
}
|
||||
const copyButton=event.target.closest("[data-copy-generated]");
|
||||
if(copyButton){try{await copyText(copyButton.closest(".file-code").querySelector("textarea").value);toast("Copied");}catch(error){toast(error.message);}return;}
|
||||
const deleteButton=event.target.closest("[data-delete-file]");
|
||||
if(deleteButton){
|
||||
if(!await askConfirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`,{title:"Delete file",confirmText:"Delete",danger:true}))return;
|
||||
try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`,{method:"DELETE",headers:getAuthToken()?{Authorization:`Bearer ${getAuthToken()}`}:{},body:JSON.stringify({access_token:accessToken||null})});toast("File deleted");await loadFiles();}catch(error){toast(error.message);}return;
|
||||
const copyButton = event.target.closest("[data-copy-generated]");
|
||||
if (copyButton) { try { await copyText(copyButton.closest(".file-code").querySelector("textarea").value); toast("Copied"); } catch (error) { toast(error.message); } return; }
|
||||
const deleteButton = event.target.closest("[data-delete-file]");
|
||||
if (deleteButton) {
|
||||
if (!await askConfirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`, { title: "Delete file", confirmText: "Delete", danger: true })) return;
|
||||
try { await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`, { method: "DELETE", headers: getAuthToken() ? { Authorization: `Bearer ${getAuthToken()}` } : {}, body: JSON.stringify({ access_token: accessToken || null }) }); toast("File deleted"); await loadFiles(); } catch (error) { toast(error.message); } return;
|
||||
}
|
||||
});
|
||||
document.querySelector("#delete-note").addEventListener("click",async()=>{if(!await askConfirm(`Delete note “${info.title}”? This cannot be undone.`,{title:"Delete note",confirmText:"Delete",danger:true}))return;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`,{method:"DELETE",body:JSON.stringify({access_token:accessToken||null})});location.assign(`/w/${encodeURIComponent(workspaceSlug)}`);}catch(error){toast(error.message);}});
|
||||
window.addEventListener("error",event=>{setStatus("offline","Application error");console.error(event.error||event.message);});
|
||||
window.addEventListener("unhandledrejection",event=>{setStatus("offline","Application error");console.error(event.reason);});
|
||||
document.querySelector("#delete-note").addEventListener("click", async () => { if (!await askConfirm(`Delete note “${info.title}”? This cannot be undone.`, { title: "Delete note", confirmText: "Delete", danger: true })) return; try { await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`, { method: "DELETE", body: JSON.stringify({ access_token: accessToken || null }) }); location.assign(`/w/${encodeURIComponent(workspaceSlug)}`); } catch (error) { toast(error.message); } });
|
||||
window.addEventListener("error", event => { setStatus("offline", "Application error"); console.error(event.error || event.message); });
|
||||
window.addEventListener("unhandledrejection", event => { setStatus("offline", "Application error"); console.error(event.reason); });
|
||||
initialize();
|
||||
|
||||
+158
-158
@@ -1,199 +1,199 @@
|
||||
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";
|
||||
|
||||
const slug=location.pathname.split("/").filter(Boolean)[1];
|
||||
const editor=document.querySelector("#editor"), preview=document.querySelector("#preview"), editorWorkspace=document.querySelector("#editor-workspace"), gutter=document.querySelector("#line-gutter"), ownerLabels=document.querySelector("#owner-labels");
|
||||
const modeToggle=document.querySelector("#mode-toggle"), passwordDialog=document.querySelector("#password-dialog"), identityDialog=document.querySelector("#identity-dialog");
|
||||
const roomDetails=document.querySelector("#room-details"), roomUsers=document.querySelector("#room-users"), roomCount=document.querySelector("#room-count"), socketLatency=document.querySelector("#socket-latency"), chatMessages=document.querySelector("#chat-messages"), chatForm=document.querySelector("#chat-form"), chatInput=document.querySelector("#chat-input"), chatUnread=document.querySelector("#chat-unread");
|
||||
let unreadChat=0;
|
||||
const compactToggle=document.querySelector("#compact-toggle"), publicTaskUpdates=document.querySelector("#public-task-updates"), fontFamily=document.querySelector("#font-family"), fontSize=document.querySelector("#font-size"), currentUser=document.querySelector("#current-user"), userColorPicker=document.querySelector("#user-color-picker");
|
||||
const shareToken=new URLSearchParams(location.search).get("share");if(shareToken)setAccessToken("pad",slug,shareToken);
|
||||
let accessToken=shareToken||getAuthToken()||getAccessToken("pad",slug), password="", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[];
|
||||
const lineToggle=document.querySelector("#line-numbers-toggle"); lineToggle.checked=localStorage.getItem("rustpad:line-numbers")!=="off";
|
||||
compactToggle.checked=localStorage.getItem("rustpad:compact")!=="off";
|
||||
fontFamily.value=localStorage.getItem("rustpad:font-family")||"mono";
|
||||
fontSize.value=localStorage.getItem("rustpad:font-size")||"14";
|
||||
function defaultColorFor(name){let h=0;for(const c of name||"?")h=(h*31+c.charCodeAt(0))%360;return `hsl(${h} 70% 62%)`;}
|
||||
function storedColorKey(name){return `rustpad:user-color:${encodeURIComponent(name||"")}`;}
|
||||
function ownerParts(owner){const raw=String(owner||"");const split=raw.lastIndexOf("\u001f");return split<0?{name:raw,color:""}:{name:raw.slice(0,split),color:raw.slice(split+1)};}
|
||||
function ownerName(owner){return ownerParts(owner).name;}
|
||||
function colorFor(owner){const parts=ownerParts(owner);return /^#[0-9a-f]{6}$/i.test(parts.color)?parts.color:defaultColorFor(parts.name);}
|
||||
function currentUserColor(){return localStorage.getItem(storedColorKey(nickname))||"";}
|
||||
function currentOwner(){const color=currentUserColor();return color?`${nickname}\u001f${color}`:nickname;}
|
||||
function updateCurrentUser(){const color=currentUserColor()||defaultColorFor(nickname);currentUser.querySelector(".user-chip__name").textContent=nickname;currentUser.style.setProperty("--owner",color);userColorPicker.value=/^#[0-9a-f]{6}$/i.test(color)?color:"#7c6cff";}
|
||||
function toast(text){const el=document.querySelector("#toast");el.textContent=text;el.classList.add("visible");setTimeout(()=>el.classList.remove("visible"),1800);}
|
||||
function updatePresence(users){const entries=Array.isArray(users)?users:[];roomCount.textContent=`${entries.length} ${entries.length===1?"user":"users"}`;roomUsers.replaceChildren(...entries.map(entry=>{const user=typeof entry==="string"?{name:entry,color:""}:entry||{};const li=document.createElement("li"),dot=document.createElement("span"),label=document.createElement("span");li.className="room-user";dot.className="room-user__dot";dot.style.setProperty("--owner",/^#[0-9a-f]{6}$/i.test(user.color||"")?user.color:defaultColorFor(user.name));label.textContent=user.name||"Guest";li.title=label.textContent;li.append(dot,label);return li;}));if(!entries.length){const li=document.createElement("li");li.textContent="No active users";roomUsers.append(li);}}
|
||||
function updateLatency(ms){socketLatency.textContent=Number.isFinite(ms)?`${ms} ms`:"— ms";}
|
||||
function appendLinkifiedText(container,value){const text=String(value||"");const urlPattern=/https?:\/\/[^\s<>{}\[\]"'`]+/gi;let index=0;for(const match of text.matchAll(urlPattern)){const start=match.index??0;if(start>index)container.append(document.createTextNode(text.slice(index,start)));let raw=match[0],trail="";while(/[),.!?:;]$/.test(raw)){trail=raw.slice(-1)+trail;raw=raw.slice(0,-1);}try{const url=new URL(raw);if(url.protocol==="http:"||url.protocol==="https:"){const link=document.createElement("a");link.href=url.href;link.textContent=raw;link.target="_blank";link.rel="noopener noreferrer";container.append(link);}else container.append(document.createTextNode(raw));}catch{container.append(document.createTextNode(raw));}if(trail)container.append(document.createTextNode(trail));index=start+match[0].length;}if(index<text.length)container.append(document.createTextNode(text.slice(index)));}
|
||||
function appendChatMessage(message){const empty=chatMessages.querySelector(".chat-empty");empty?.remove();const row=document.createElement("p");row.className="chat-message";const author=document.createElement("strong");author.textContent=message.sender;const text=document.createElement("span");appendLinkifiedText(text,message.text);row.append(author,text);chatMessages.append(row);while(chatMessages.children.length>100)chatMessages.firstElementChild.remove();chatMessages.scrollTop=chatMessages.scrollHeight;if(message.sender!==nickname&&!roomDetails.open){unreadChat++;chatUnread.hidden=false;chatUnread.textContent=unreadChat>99?"99+":String(unreadChat);const oldTitle=document.title;if(!document.title.startsWith("● "))document.title=`● ${oldTitle}`;if(document.hidden&&Notification.permission==="granted")new Notification(`${message.sender} wrote in RustPad`,{body:message.text.slice(0,160),tag:"rustpad-room-chat"});}}
|
||||
function clearUnread(){unreadChat=0;chatUnread.hidden=true;chatUnread.textContent="";document.title=document.title.replace(/^● /,"");}
|
||||
const slug = location.pathname.split("/").filter(Boolean)[1];
|
||||
const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels");
|
||||
const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
|
||||
const roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread");
|
||||
let unreadChat = 0;
|
||||
const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker");
|
||||
const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken("pad", slug, shareToken);
|
||||
let accessToken = shareToken || getAuthToken() || getAccessToken("pad", slug), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState(), owners = [];
|
||||
const lineToggle = document.querySelector("#line-numbers-toggle"); lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off";
|
||||
compactToggle.checked = localStorage.getItem("rustpad:compact") !== "off";
|
||||
fontFamily.value = localStorage.getItem("rustpad:font-family") || "mono";
|
||||
fontSize.value = localStorage.getItem("rustpad:font-size") || "14";
|
||||
function defaultColorFor(name) { let h = 0; for (const c of name || "?") h = (h * 31 + c.charCodeAt(0)) % 360; return `hsl(${h} 70% 62%)`; }
|
||||
function storedColorKey(name) { return `rustpad:user-color:${encodeURIComponent(name || "")}`; }
|
||||
function ownerParts(owner) { const raw = String(owner || ""); const split = raw.lastIndexOf("\u001f"); return split < 0 ? { name: raw, color: "" } : { name: raw.slice(0, split), color: raw.slice(split + 1) }; }
|
||||
function ownerName(owner) { return ownerParts(owner).name; }
|
||||
function colorFor(owner) { const parts = ownerParts(owner); return /^#[0-9a-f]{6}$/i.test(parts.color) ? parts.color : defaultColorFor(parts.name); }
|
||||
function currentUserColor() { return localStorage.getItem(storedColorKey(nickname)) || ""; }
|
||||
function currentOwner() { const color = currentUserColor(); return color ? `${nickname}\u001f${color}` : nickname; }
|
||||
function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); userColorPicker.value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; }
|
||||
function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1800); }
|
||||
function updatePresence(users) { const entries = Array.isArray(users) ? users : []; roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...entries.map(entry => { const user = typeof entry === "string" ? { name: entry, color: "" } : entry || {}; const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } }
|
||||
function updateLatency(ms) { socketLatency.textContent = Number.isFinite(ms) ? `${ms} ms` : "— ms"; }
|
||||
function appendLinkifiedText(container, value) { const text = String(value || ""); const urlPattern = /https?:\/\/[^\s<>{}\[\]"'`]+/gi; let index = 0; for (const match of text.matchAll(urlPattern)) { const start = match.index ?? 0; if (start > index) container.append(document.createTextNode(text.slice(index, start))); let raw = match[0], trail = ""; while (/[),.!?:;]$/.test(raw)) { trail = raw.slice(-1) + trail; raw = raw.slice(0, -1); } try { const url = new URL(raw); if (url.protocol === "http:" || url.protocol === "https:") { const link = document.createElement("a"); link.href = url.href; link.textContent = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; container.append(link); } else container.append(document.createTextNode(raw)); } catch { container.append(document.createTextNode(raw)); } if (trail) container.append(document.createTextNode(trail)); index = start + match[0].length; } if (index < text.length) container.append(document.createTextNode(text.slice(index))); }
|
||||
function appendChatMessage(message) { const empty = chatMessages.querySelector(".chat-empty"); empty?.remove(); const row = document.createElement("p"); row.className = "chat-message"; const author = document.createElement("strong"); author.textContent = message.sender; const text = document.createElement("span"); appendLinkifiedText(text, message.text); row.append(author, text); chatMessages.append(row); while (chatMessages.children.length > 100) chatMessages.firstElementChild.remove(); chatMessages.scrollTop = chatMessages.scrollHeight; if (message.sender !== nickname && !roomDetails.open) { unreadChat++; chatUnread.hidden = false; chatUnread.textContent = unreadChat > 99 ? "99+" : String(unreadChat); const oldTitle = document.title; if (!document.title.startsWith("● ")) document.title = `● ${oldTitle}`; if (document.hidden && Notification.permission === "granted") new Notification(`${message.sender} wrote in RustPad`, { body: message.text.slice(0, 160), tag: "rustpad-room-chat" }); } }
|
||||
function clearUnread() { unreadChat = 0; chatUnread.hidden = true; chatUnread.textContent = ""; document.title = document.title.replace(/^● /, ""); }
|
||||
|
||||
function setStatus(kind,text){document.querySelector("#status-dot").className=`status__dot${kind?` is-${kind}`:""}`;document.querySelector("#status-text").textContent=text;}
|
||||
function updateAddressLabel(){document.querySelector("#pad-url").textContent=`${location.pathname}${location.search}`;}
|
||||
async function renderMermaid(){const nodes=preview.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'<p class="error">Failed to load Mermaid.</p>'));}}
|
||||
async function renderCodeHighlight(){const nodes=preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)');if(!nodes.length)return;try{const hljs=await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm");nodes.forEach(node=>{const lines=node.querySelectorAll(".code-line");if(!lines.length){hljs.default.highlightElement(node);return;}const language=[...node.classList].find(name=>name.startsWith("language-"))?.slice(9);lines.forEach(line=>{try{line.innerHTML=hljs.default.highlight(line.textContent,{language,ignoreIllegals:true}).value;}catch{line.innerHTML=hljs.default.highlightAuto(line.textContent).value;}});node.classList.add("hljs");});}catch{}}
|
||||
function renderGutter(){
|
||||
const lineCount=Math.max(1,(editor.value.match(/\n/g)||[]).length+1);
|
||||
const lines=Array.from({length:lineCount});
|
||||
owners=owners.slice(0,lineCount);
|
||||
while(owners.length<lineCount)owners.push(owners.at(-1)||currentOwner()||"");
|
||||
const style=getComputedStyle(editor), lineHeight=parseFloat(style.lineHeight)||29, paddingTop=parseFloat(style.paddingTop)||24, paddingBottom=parseFloat(style.paddingBottom)||24;
|
||||
gutter.style.paddingTop=`${paddingTop}px`;gutter.style.paddingBottom=`${paddingBottom}px`;gutter.style.lineHeight=`${lineHeight}px`;
|
||||
gutter.innerHTML=lines.map((_,i)=>`<div style="height:${lineHeight}px">${i+1}</div>`).join("");
|
||||
ownerLabels.style.setProperty("--editor-line-height",`${lineHeight}px`);
|
||||
ownerLabels.innerHTML=lines.map((_,i)=>{
|
||||
const owner=owners[i]||"";
|
||||
if(!owner)return "";
|
||||
const top=paddingTop+i*lineHeight-editor.scrollTop;
|
||||
const label=owner!==owners[i-1]?`<span class="owner-label" style="top:${top}px;--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`:"";
|
||||
function setStatus(kind, text) { document.querySelector("#status-dot").className = `status__dot${kind ? ` is-${kind}` : ""}`; document.querySelector("#status-text").textContent = text; }
|
||||
function updateAddressLabel() { document.querySelector("#pad-url").textContent = `${location.pathname}${location.search}`; }
|
||||
async function renderMermaid() { const nodes = preview.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
|
||||
async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } }
|
||||
function renderGutter() {
|
||||
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
|
||||
const lines = Array.from({ length: lineCount });
|
||||
owners = owners.slice(0, lineCount);
|
||||
while (owners.length < lineCount) owners.push(owners.at(-1) || currentOwner() || "");
|
||||
const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, paddingTop = parseFloat(style.paddingTop) || 24, paddingBottom = parseFloat(style.paddingBottom) || 24;
|
||||
gutter.style.paddingTop = `${paddingTop}px`; gutter.style.paddingBottom = `${paddingBottom}px`; gutter.style.lineHeight = `${lineHeight}px`;
|
||||
gutter.innerHTML = lines.map((_, i) => `<div style="height:${lineHeight}px">${i + 1}</div>`).join("");
|
||||
ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
|
||||
ownerLabels.innerHTML = lines.map((_, i) => {
|
||||
const owner = owners[i] || "";
|
||||
if (!owner) return "";
|
||||
const top = paddingTop + i * lineHeight - editor.scrollTop;
|
||||
const label = owner !== owners[i - 1] ? `<span class="owner-label" style="top:${top}px;--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>` : "";
|
||||
return `<span class="owner-line" style="top:${top}px;--owner:${colorFor(owner)}"></span>${label}`;
|
||||
}).join("");
|
||||
document.body.classList.toggle("hide-line-numbers",!lineToggle.checked);
|
||||
document.body.classList.toggle("hide-line-numbers", !lineToggle.checked);
|
||||
}
|
||||
function escapeHtml(v){return String(v).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));}function formatDate(value){const raw=String(value??"").trim();let normalized=raw;if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized))normalized=normalized.replace(" ","T")+":00";else if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized))normalized=normalized.replace(" ","T");else if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized))normalized=normalized.replace(" ","T")+"Z";const date=new Date(normalized);return Number.isNaN(date.getTime())?raw:date.toLocaleString("pl-PL",{year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"});}
|
||||
function escapeHtml(v) { return String(v).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } function formatDate(value) { const raw = String(value ?? "").trim(); let normalized = raw; if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T") + ":00"; else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T"); else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized)) normalized = normalized.replace(" ", "T") + "Z"; const date = new Date(normalized); return Number.isNaN(date.getTime()) ? raw : date.toLocaleString("pl-PL", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); }
|
||||
|
||||
function markdownFromPreview(node){
|
||||
const walk=current=>{
|
||||
if(current.nodeType===Node.TEXT_NODE)return current.nodeValue||"";
|
||||
if(current.nodeType!==Node.ELEMENT_NODE)return "";
|
||||
const tag=current.tagName.toLowerCase(),body=[...current.childNodes].map(walk).join("");
|
||||
if(tag==="strong"||tag==="b")return `**${body}**`;
|
||||
if(tag==="em"||tag==="i")return `*${body}*`;
|
||||
if(tag==="s"||tag==="del")return `~~${body}~~`;
|
||||
if(tag==="mark")return `==${body}==`;
|
||||
if(tag==="code")return "`"+body+"`";
|
||||
if(tag==="sub")return `~${body}~`;
|
||||
if(tag==="sup"&&!current.classList.contains("footnote-ref"))return `^${body}^`;
|
||||
if(tag==="a")return `[${body}](${current.getAttribute("href")||"#"})`;
|
||||
if(tag==="img"){
|
||||
const src=current.getAttribute("src")||"";
|
||||
const alt=current.getAttribute("alt")||"";
|
||||
const title=current.getAttribute("title");
|
||||
return `}"`:""})`;
|
||||
function markdownFromPreview(node) {
|
||||
const walk = current => {
|
||||
if (current.nodeType === Node.TEXT_NODE) return current.nodeValue || "";
|
||||
if (current.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
const tag = current.tagName.toLowerCase(), body = [...current.childNodes].map(walk).join("");
|
||||
if (tag === "strong" || tag === "b") return `**${body}**`;
|
||||
if (tag === "em" || tag === "i") return `*${body}*`;
|
||||
if (tag === "s" || tag === "del") return `~~${body}~~`;
|
||||
if (tag === "mark") return `==${body}==`;
|
||||
if (tag === "code") return "`" + body + "`";
|
||||
if (tag === "sub") return `~${body}~`;
|
||||
if (tag === "sup" && !current.classList.contains("footnote-ref")) return `^${body}^`;
|
||||
if (tag === "a") return `[${body}](${current.getAttribute("href") || "#"})`;
|
||||
if (tag === "img") {
|
||||
const src = current.getAttribute("src") || "";
|
||||
const alt = current.getAttribute("alt") || "";
|
||||
const title = current.getAttribute("title");
|
||||
return `}"` : ""})`;
|
||||
}
|
||||
if(tag==="br")return " ";
|
||||
if (tag === "br") return " ";
|
||||
return body;
|
||||
};
|
||||
return [...node.childNodes].map(walk).join("").replace(/\n/g," ").trim();
|
||||
return [...node.childNodes].map(walk).join("").replace(/\n/g, " ").trim();
|
||||
}
|
||||
|
||||
function previewCaretOffset(target){
|
||||
const selection=window.getSelection();
|
||||
if(!selection?.rangeCount)return 0;
|
||||
const range=selection.getRangeAt(0);
|
||||
if(!target.contains(range.startContainer))return 0;
|
||||
const prefix=range.cloneRange();
|
||||
function previewCaretOffset(target) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) return 0;
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!target.contains(range.startContainer)) return 0;
|
||||
const prefix = range.cloneRange();
|
||||
prefix.selectNodeContents(target);
|
||||
prefix.setEnd(range.startContainer,range.startOffset);
|
||||
prefix.setEnd(range.startContainer, range.startOffset);
|
||||
return prefix.toString().length;
|
||||
}
|
||||
function placePreviewCaret(target,offset){
|
||||
const walker=document.createTreeWalker(target,NodeFilter.SHOW_TEXT);
|
||||
let remaining=Math.max(0,offset),node;
|
||||
while((node=walker.nextNode())){
|
||||
if(remaining<=node.nodeValue.length){
|
||||
const range=document.createRange();range.setStart(node,remaining);range.collapse(true);
|
||||
const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);return;
|
||||
function placePreviewCaret(target, offset) {
|
||||
const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT);
|
||||
let remaining = Math.max(0, offset), node;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (remaining <= node.nodeValue.length) {
|
||||
const range = document.createRange(); range.setStart(node, remaining); range.collapse(true);
|
||||
const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); return;
|
||||
}
|
||||
remaining-=node.nodeValue.length;
|
||||
remaining -= node.nodeValue.length;
|
||||
}
|
||||
const range=document.createRange();range.selectNodeContents(target);range.collapse(false);
|
||||
const selection=window.getSelection();selection.removeAllRanges();selection.addRange(range);
|
||||
const range = document.createRange(); range.selectNodeContents(target); range.collapse(false);
|
||||
const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range);
|
||||
}
|
||||
function movePreviewCaret(target,direction){
|
||||
const editables=[...preview.querySelectorAll(".preview-editable")];
|
||||
const index=editables.indexOf(target),next=editables[index+direction];
|
||||
if(!next)return false;
|
||||
const offset=previewCaretOffset(target);next.focus();placePreviewCaret(next,offset);next.scrollIntoView({block:"nearest"});return true;
|
||||
function movePreviewCaret(target, direction) {
|
||||
const editables = [...preview.querySelectorAll(".preview-editable")];
|
||||
const index = editables.indexOf(target), next = editables[index + direction];
|
||||
if (!next) return false;
|
||||
const offset = previewCaretOffset(target); next.focus(); placePreviewCaret(next, offset); next.scrollIntoView({ block: "nearest" }); return true;
|
||||
}
|
||||
function continueIndentation(event){
|
||||
if(event.key!=="Enter"||event.shiftKey||event.ctrlKey||event.metaKey||event.altKey)return;
|
||||
const start=editor.selectionStart,end=editor.selectionEnd;
|
||||
const lineStart=editor.value.lastIndexOf("\n",start-1)+1;
|
||||
const current=editor.value.slice(lineStart,start);
|
||||
const indent=(current.match(/^[ \t]*/)||[""])[0];
|
||||
if(!indent)return;
|
||||
function continueIndentation(event) {
|
||||
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
const start = editor.selectionStart, end = editor.selectionEnd;
|
||||
const lineStart = editor.value.lastIndexOf("\n", start - 1) + 1;
|
||||
const current = editor.value.slice(lineStart, start);
|
||||
const indent = (current.match(/^[ \t]*/) || [""])[0];
|
||||
if (!indent) return;
|
||||
event.preventDefault();
|
||||
editor.setRangeText(`\n${indent}`,start,end,"end");
|
||||
editor.dispatchEvent(new Event("input",{bubbles:true}));
|
||||
editor.setRangeText(`\n${indent}`, start, end, "end");
|
||||
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
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]}`;}
|
||||
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]}`; }
|
||||
|
||||
function replaceTableCell(line,index,value){
|
||||
const leading=line.trimStart().startsWith("|"),trailing=line.trimEnd().endsWith("|");
|
||||
let body=line.trim();if(leading)body=body.slice(1);if(trailing)body=body.slice(0,-1);
|
||||
const cells=body.split("|").map(cell=>cell.trim());while(cells.length<=index)cells.push("");cells[index]=value.replace(/\|/g,"|");
|
||||
return `${leading?"| ":""}${cells.join(" | ")}${trailing?" |":""}`;
|
||||
function replaceTableCell(line, index, value) {
|
||||
const leading = line.trimStart().startsWith("|"), trailing = line.trimEnd().endsWith("|");
|
||||
let body = line.trim(); if (leading) body = body.slice(1); if (trailing) body = body.slice(0, -1);
|
||||
const cells = body.split("|").map(cell => cell.trim()); while (cells.length <= index) cells.push(""); cells[index] = value.replace(/\|/g, "|");
|
||||
return `${leading ? "| " : ""}${cells.join(" | ")}${trailing ? " |" : ""}`;
|
||||
}
|
||||
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 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(); }
|
||||
|
||||
async function loadFiles({open=false}={}){
|
||||
try{
|
||||
const files=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"PUT",body:JSON.stringify({access_token:accessToken||null})});
|
||||
const totalSize=files.reduce((sum,file)=>sum+(Number(file.size_bytes)||0),0);
|
||||
document.querySelector("#footer-files").textContent=`${files.length} ${files.length===1?"file":"files"} · ${formatBytes(totalSize)}`;
|
||||
document.querySelector("#files-list").innerHTML=files.length?files.map(file=>`<div class="file-row" data-file-row="${file.id}"><div class="file-row-main"><div class="file-name">${escapeHtml(file.filename)}</div><div class="file-meta">${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)} · ${formatDate(file.created_at)} · <span class="file-flag${file.is_attached?"":" detached"}">${file.is_attached?"in note":"removed from content"}</span></div></div><div class="file-actions"><button data-show-file-code="link" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Link</button><button data-show-file-code="markdown" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Markdown</button><button data-show-file-code="html" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">HTML</button>${info?.can_delete_files?`<button class="file-delete" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>`:""}</div><div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button data-copy-generated>Copy</button></div></div>`).join(""):'<p class="dialog-copy">No files uploaded.</p>';
|
||||
if(open)document.querySelector("#files-dialog").showModal();
|
||||
}catch(error){if(open)toast(error.message);}
|
||||
async function loadFiles({ open = false } = {}) {
|
||||
try {
|
||||
const files = await api(`/api/pads/${encodeURIComponent(slug)}/files`, { method: "PUT", body: JSON.stringify({ access_token: accessToken || null }) });
|
||||
const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0);
|
||||
document.querySelector("#footer-files").textContent = `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`;
|
||||
document.querySelector("#files-list").innerHTML = files.length ? files.map(file => `<div class="file-row" data-file-row="${file.id}"><div class="file-row-main"><div class="file-name">${escapeHtml(file.filename)}</div><div class="file-meta">${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)} · ${formatDate(file.created_at)} · <span class="file-flag${file.is_attached ? "" : " detached"}">${file.is_attached ? "in note" : "removed from content"}</span></div></div><div class="file-actions"><button data-show-file-code="link" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Link</button><button data-show-file-code="markdown" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Markdown</button><button data-show-file-code="html" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">HTML</button>${info?.can_delete_files ? `<button class="file-delete" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>` : ""}</div><div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button data-copy-generated>Copy</button></div></div>`).join("") : '<p class="dialog-copy">No files uploaded.</p>';
|
||||
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();}
|
||||
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>`;}}
|
||||
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>`; } }
|
||||
|
||||
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();});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});
|
||||
publicTaskUpdates.addEventListener("change",async()=>{publicTaskUpdates.disabled=true;try{await api(`/api/pads/${encodeURIComponent(slug)}/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/pads/${encodeURIComponent(slug)}/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();});
|
||||
chatForm.addEventListener("submit",event=>{event.preventDefault();const text=chatInput.value.trim();if(!text||!socket)return;socket.chat(text);chatInput.value="";chatInput.focus();});
|
||||
if(!chatMessages.children.length){const empty=document.createElement("p");empty.className="chat-empty";empty.textContent="No messages yet";chatMessages.append(empty);}
|
||||
currentUser.addEventListener("click",()=>userColorPicker.click());
|
||||
userColorPicker.addEventListener("input",()=>{
|
||||
localStorage.setItem(storedColorKey(nickname),userColorPicker.value);
|
||||
const replacement=currentOwner();
|
||||
owners=owners.map(owner=>ownerName(owner)===nickname?replacement:owner);
|
||||
updateCurrentUser();render();
|
||||
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(); }); 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 });
|
||||
publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await api(`/api/pads/${encodeURIComponent(slug)}/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/pads/${encodeURIComponent(slug)}/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(); });
|
||||
chatForm.addEventListener("submit", event => { event.preventDefault(); const text = chatInput.value.trim(); if (!text || !socket) return; socket.chat(text); chatInput.value = ""; chatInput.focus(); });
|
||||
if (!chatMessages.children.length) { const empty = document.createElement("p"); empty.className = "chat-empty"; empty.textContent = "No messages yet"; chatMessages.append(empty); }
|
||||
currentUser.addEventListener("click", () => userColorPicker.click());
|
||||
userColorPicker.addEventListener("input", () => {
|
||||
localStorage.setItem(storedColorKey(nickname), userColorPicker.value);
|
||||
const replacement = currentOwner();
|
||||
owners = owners.map(owner => ownerName(owner) === nickname ? replacement : owner);
|
||||
updateCurrentUser(); render();
|
||||
socket?.setColor(userColorPicker.value);
|
||||
if(socket)socket.update(editor.value,JSON.stringify(owners));
|
||||
if (socket) socket.update(editor.value, JSON.stringify(owners));
|
||||
});
|
||||
editor.addEventListener("keydown",continueIndentation);editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.length<newLines)owners.push(currentOwner());owners=owners.slice(0,newLines);owners[cursorLine]=currentOwner();render();if(applyingRemote)return;clearTimeout(saveTimer);document.querySelector("#save-state").textContent="Saving…";saveTimer=setTimeout(()=>socket?.update(editor.value,JSON.stringify(owners)),250);});
|
||||
document.querySelector("#password-form").addEventListener("submit",async e=>{e.preventDefault();try{password=document.querySelector("#open-password").value;const result=await api("/api/access-token",{method:"POST",body:JSON.stringify({kind:"pad",slug,password})});accessToken=result.access_token;setAccessToken("pad",slug,accessToken);password="";document.querySelector("#open-password").value="";document.querySelector("#password-error").textContent="";connect();}catch(error){document.querySelector("#password-error").textContent=error.message;}});
|
||||
const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='<p class="empty">Loading…</p>';try{const revisions=await api(`/api/pads/${encodeURIComponent(slug)}/history`,{method:"POST",body:JSON.stringify({access_token:accessToken||null})});list.innerHTML=revisions.length?revisions.map((r,i)=>{const snippet=escapeHtml(r.content.trim().split("\n").slice(0,3).join(" · ").slice(0,150)||"Empty note");const author=r.author||"Unknown author";return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`;}).join(""):'<p class="empty">No history yet.</p>';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/pads/${encodeURIComponent(slug)}/restore`,{method:"POST",body:JSON.stringify({access_token:accessToken||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`<p class="error">${escapeHtml(e.message)}</p>`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");});
|
||||
document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{let file=e.target.files[0];if(!file)return;if(file.type.startsWith("image/")){file=await prepareImageFile(file);if(!file){e.target.value="";return;}}const form=new FormData();form.append("access_token",accessToken||"");form.append("file",file);try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?``:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");loadFiles();}catch(err){toast(err.message);}e.target.value="";});
|
||||
editor.addEventListener("keydown", continueIndentation); editor.addEventListener("scroll", () => { gutter.scrollTop = editor.scrollTop; renderGutter(); }); editor.addEventListener("input", () => { const newLines = editor.value.split("\n").length; const cursorLine = editor.value.slice(0, editor.selectionStart).split("\n").length - 1; while (owners.length < newLines) owners.push(currentOwner()); owners = owners.slice(0, newLines); owners[cursorLine] = currentOwner(); render(); if (applyingRemote) return; clearTimeout(saveTimer); document.querySelector("#save-state").textContent = "Saving…"; saveTimer = setTimeout(() => socket?.update(editor.value, JSON.stringify(owners)), 250); });
|
||||
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "pad", slug, password }) }); accessToken = result.access_token; setAccessToken("pad", slug, accessToken); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } });
|
||||
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await api(`/api/pads/${encodeURIComponent(slug)}/history`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null }) }); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await api(`/api/pads/${encodeURIComponent(slug)}/restore`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null, revision_id: r.id }) }); toast("Version restored"); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
|
||||
document.querySelector("#upload-button").addEventListener("click", () => document.querySelector("#file-input").click()); document.querySelector("#file-input").addEventListener("change", async e => { let file = e.target.files[0]; if (!file) return; if (file.type.startsWith("image/")) { file = await prepareImageFile(file); if (!file) { e.target.value = ""; return; } } const form = new FormData(); form.append("access_token", accessToken || ""); form.append("file", file); try { const result = await api(`/api/pads/${encodeURIComponent(slug)}/files`, { method: "POST", body: form, headers: {} }); const image = file.type.startsWith("image/"); const text = image ? `` : `[${file.name}](${result.url})`; editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end"); editor.dispatchEvent(new Event("input")); toast("File uploaded"); loadFiles(); } catch (err) { toast(err.message); } e.target.value = ""; });
|
||||
|
||||
document.querySelector("#files-button").addEventListener("click",()=>loadFiles({open:true}));
|
||||
document.querySelector("#footer-files").addEventListener("click",()=>loadFiles({open:true}));
|
||||
document.querySelector("#close-files").addEventListener("click",()=>document.querySelector("#files-dialog").close());
|
||||
document.querySelector("#files-list").addEventListener("click",async event=>{
|
||||
const showButton=event.target.closest("[data-show-file-code]");
|
||||
if(showButton){
|
||||
const row=showButton.closest(".file-row"), panel=row.querySelector(".file-code"), output=panel.querySelector("textarea");
|
||||
const absolute=new URL(showButton.dataset.url,location.origin).href;
|
||||
let text=absolute;
|
||||
if(showButton.dataset.showFileCode==="markdown")text=showButton.dataset.mime?.startsWith("image/")?``:`[${showButton.dataset.name}](${absolute})`;
|
||||
if(showButton.dataset.showFileCode==="html")text=(showButton.dataset.mime||"").startsWith("image/")?`<img src="${absolute}" alt="${showButton.dataset.name}">`:`<a href="${absolute}">${showButton.dataset.name}</a>`;
|
||||
output.value=text;panel.hidden=false;output.focus();output.select();return;
|
||||
document.querySelector("#files-button").addEventListener("click", () => loadFiles({ open: true }));
|
||||
document.querySelector("#footer-files").addEventListener("click", () => loadFiles({ open: true }));
|
||||
document.querySelector("#close-files").addEventListener("click", () => document.querySelector("#files-dialog").close());
|
||||
document.querySelector("#files-list").addEventListener("click", async event => {
|
||||
const showButton = event.target.closest("[data-show-file-code]");
|
||||
if (showButton) {
|
||||
const row = showButton.closest(".file-row"), panel = row.querySelector(".file-code"), output = panel.querySelector("textarea");
|
||||
const absolute = new URL(showButton.dataset.url, location.origin).href;
|
||||
let text = absolute;
|
||||
if (showButton.dataset.showFileCode === "markdown") text = showButton.dataset.mime?.startsWith("image/") ? `` : `[${showButton.dataset.name}](${absolute})`;
|
||||
if (showButton.dataset.showFileCode === "html") text = (showButton.dataset.mime || "").startsWith("image/") ? `<img src="${absolute}" alt="${showButton.dataset.name}">` : `<a href="${absolute}">${showButton.dataset.name}</a>`;
|
||||
output.value = text; panel.hidden = false; output.focus(); output.select(); return;
|
||||
}
|
||||
const copyButton=event.target.closest("[data-copy-generated]");
|
||||
if(copyButton){try{await copyText(copyButton.closest(".file-code").querySelector("textarea").value);toast("Copied");}catch(error){toast(error.message);}return;}
|
||||
const deleteButton=event.target.closest("[data-delete-file]");
|
||||
if(deleteButton){
|
||||
if(!confirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`))return;
|
||||
try{await api(`/api/pads/${encodeURIComponent(slug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`,{method:"DELETE",headers:getAuthToken()?{Authorization:`Bearer ${getAuthToken()}`}:{},body:JSON.stringify({access_token:accessToken||null})});toast("File deleted");await loadFiles();}catch(error){toast(error.message);}return;
|
||||
const copyButton = event.target.closest("[data-copy-generated]");
|
||||
if (copyButton) { try { await copyText(copyButton.closest(".file-code").querySelector("textarea").value); toast("Copied"); } catch (error) { toast(error.message); } return; }
|
||||
const deleteButton = event.target.closest("[data-delete-file]");
|
||||
if (deleteButton) {
|
||||
if (!confirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`)) return;
|
||||
try { await api(`/api/pads/${encodeURIComponent(slug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`, { method: "DELETE", headers: getAuthToken() ? { Authorization: `Bearer ${getAuthToken()}` } : {}, body: JSON.stringify({ access_token: accessToken || null }) }); toast("File deleted"); await loadFiles(); } catch (error) { toast(error.message); } return;
|
||||
}
|
||||
});
|
||||
initialize();
|
||||
|
||||
+15
-15
@@ -1,23 +1,23 @@
|
||||
import { installGlobalDiagnostics, logInfo } from "./logger.js";
|
||||
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
|
||||
installGlobalDiagnostics();
|
||||
|
||||
import { api } from "@rustpad/api";
|
||||
import { copyText } from "@rustpad/clipboard";
|
||||
import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown";
|
||||
|
||||
const token=location.pathname.split("/").filter(Boolean)[1];
|
||||
const content=document.querySelector("#public-content");
|
||||
const lineNumbersToggle=document.querySelector("#public-line-numbers-toggle");
|
||||
function toast(text){const el=document.querySelector("#toast");el.textContent=text;el.classList.add("visible");setTimeout(()=>el.classList.remove("visible"),1800);}
|
||||
async function renderMermaid(){const nodes=content.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'<p class="error">Failed to load Mermaid.</p>'));}}
|
||||
async function renderCodeHighlight(){const blocks=content.querySelectorAll('pre code[class^="language-"]');if(!blocks.length)return;try{const hljs=await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm");blocks.forEach(block=>{const lines=block.querySelectorAll(".code-line");if(!lines.length){hljs.default.highlightElement(block);return;}const language=[...block.classList].find(name=>name.startsWith("language-"))?.slice(9);lines.forEach(line=>{try{line.innerHTML=hljs.default.highlight(line.textContent,{language,ignoreIllegals:true}).value;}catch{line.innerHTML=hljs.default.highlightAuto(line.textContent).value;}});block.classList.add("hljs");});}catch{}}
|
||||
function lockPublicContent(allowTaskUpdates){
|
||||
content.querySelectorAll('[contenteditable]').forEach(node=>node.removeAttribute('contenteditable'));
|
||||
content.querySelectorAll('.preview-editable').forEach(node=>node.classList.remove('preview-editable'));
|
||||
content.querySelectorAll('.task-checkbox').forEach(box=>{box.disabled=!allowTaskUpdates;box.title=allowTaskUpdates?'Update this task':'Task updates are disabled by the owner';});
|
||||
const token = location.pathname.split("/").filter(Boolean)[1];
|
||||
const content = document.querySelector("#public-content");
|
||||
const lineNumbersToggle = document.querySelector("#public-line-numbers-toggle");
|
||||
function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1800); }
|
||||
async function renderMermaid() { const nodes = content.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
|
||||
async function renderCodeHighlight() { const blocks = content.querySelectorAll('pre code[class^="language-"]'); if (!blocks.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); blocks.forEach(block => { const lines = block.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(block); return; } const language = [...block.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); block.classList.add("hljs"); }); } catch { } }
|
||||
function lockPublicContent(allowTaskUpdates) {
|
||||
content.querySelectorAll('[contenteditable]').forEach(node => node.removeAttribute('contenteditable'));
|
||||
content.querySelectorAll('.preview-editable').forEach(node => node.classList.remove('preview-editable'));
|
||||
content.querySelectorAll('.task-checkbox').forEach(box => { box.disabled = !allowTaskUpdates; box.title = allowTaskUpdates ? 'Update this task' : 'Task updates are disabled by the owner'; });
|
||||
}
|
||||
async function initialize(){try{const page=await api(`/api/public/${encodeURIComponent(token)}`);document.querySelector("#public-title").textContent=page.title;document.querySelector("#public-meta").textContent=`Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates?" · tasks can be updated":""}`;document.title=`${page.title} · RustPad`;content.innerHTML=renderMarkdown(page.content);alignPreviewLineNumbers(content);lockPublicContent(page.allow_task_updates);await Promise.all([renderMermaid(),renderCodeHighlight()]);}catch(error){content.innerHTML=`<p class="error">${String(error.message)}</p>`;}}
|
||||
content.addEventListener("change",async event=>{const box=event.target.closest(".task-checkbox");if(!box||box.disabled)return;const previous=!box.checked;box.disabled=true;try{const page=await api(`/api/public/${encodeURIComponent(token)}/tasks`,{method:"POST",body:JSON.stringify({source_line:Number(box.dataset.sourceLine),checked:box.checked})});document.querySelector("#public-meta").textContent=`Updated: ${new Date(page.updated_at).toLocaleString("en-US")} · tasks can be updated`;toast("Task saved");}catch(error){box.checked=previous;toast(error.message);}finally{box.disabled=false;}});
|
||||
lineNumbersToggle.addEventListener("change",()=>{document.body.classList.toggle("hide-preview-line-numbers",!lineNumbersToggle.checked);});
|
||||
document.querySelector("#copy-public-link").addEventListener("click",async()=>{try{await copyText(location.href);toast("Link copied");}catch(error){toast(error.message);}});
|
||||
async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`); document.querySelector("#public-title").textContent = page.title; document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`; document.title = `${page.title} · RustPad`; content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight()]); } catch (error) { content.innerHTML = `<p class="error">${String(error.message)}</p>`; } }
|
||||
content.addEventListener("change", async event => { const box = event.target.closest(".task-checkbox"); if (!box || box.disabled) return; const previous = !box.checked; box.disabled = true; try { const page = await api(`/api/public/${encodeURIComponent(token)}/tasks`, { method: "POST", body: JSON.stringify({ source_line: Number(box.dataset.sourceLine), checked: box.checked }) }); document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")} · tasks can be updated`; toast("Task saved"); } catch (error) { box.checked = previous; toast(error.message); } finally { box.disabled = false; } });
|
||||
lineNumbersToggle.addEventListener("change", () => { document.body.classList.toggle("hide-preview-line-numbers", !lineNumbersToggle.checked); });
|
||||
document.querySelector("#copy-public-link").addEventListener("click", async () => { try { await copyText(location.href); toast("Link copied"); } catch (error) { toast(error.message); } });
|
||||
initialize();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
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];
|
||||
let info;
|
||||
const shareToken=new URLSearchParams(location.search).get("share");
|
||||
let accessToken=shareToken||getAuthToken()||getAccessToken("workspace",slug);
|
||||
if(shareToken)setAccessToken("workspace",slug,shareToken);
|
||||
const shareToken = new URLSearchParams(location.search).get("share");
|
||||
let accessToken = shareToken || getAuthToken() || getAccessToken("workspace", slug);
|
||||
if (shareToken) setAccessToken("workspace", slug, shareToken);
|
||||
const dialog = document.querySelector("#password-dialog");
|
||||
const notesList = document.querySelector("#notes-list");
|
||||
const notesViewKey = `rustpad:workspace:${slug}:notes-view`;
|
||||
|
||||
+12
-4
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user